Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e375252d65 |
@@ -16,10 +16,6 @@ __pycache__/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.venv/
|
||||
tests/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
|
||||
+17
-127
@@ -13,15 +13,6 @@ SUPPORT_USERNAME=@support
|
||||
# Имя пользователя бота (опционально, автоопределяется)
|
||||
# BOT_USERNAME=
|
||||
|
||||
# ===== СЕТЬ И ПРОКСИ =====
|
||||
# URL SOCKS5 прокси-сервера для маршрутизации трафика бота к Telegram API
|
||||
# Формат: socks5://user:password@host:port или socks5://host:port
|
||||
# PROXY_URL=socks5://127.0.0.1:1080
|
||||
|
||||
# Альтернативный URL сервера Telegram Bot API (для регионов где api.telegram.org заблокирован)
|
||||
# Примеры: Cloudflare Worker, self-hosted telegram-bot-api (tdlib), любой совместимый прокси
|
||||
# TELEGRAM_API_URL=https://your-telegram-proxy.workers.dev
|
||||
|
||||
# ===== СИСТЕМА ПОДДЕРЖКИ =====
|
||||
# Включить меню поддержки в интерфейсе
|
||||
SUPPORT_MENU_ENABLED=true
|
||||
@@ -125,8 +116,10 @@ BLACKLIST_UPDATE_INTERVAL_HOURS=24 # Интервал обновле
|
||||
BLACKLIST_IGNORE_ADMINS=true # Игнорировать администраторов (из ADMIN_IDS) при проверке черного списка
|
||||
SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS=20000 # Порог баланса (в копейках) для фильтра «готовы к продлению»
|
||||
|
||||
# Channel subscription settings (channels are managed via admin panel)
|
||||
# Обязательная подписка на канал
|
||||
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
|
||||
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
|
||||
CHANNEL_LINK= # Опционально ссылка на канал
|
||||
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE=true # Отключать триальные подписки при отписке от канала
|
||||
CHANNEL_REQUIRED_FOR_ALL=false # Требовать подписку на канал для ВСЕХ пользователей (платных и триальных)
|
||||
|
||||
@@ -203,9 +196,6 @@ REMNAWAVE_WEBHOOK_PATH=/remnawave-webhook
|
||||
# Сгенерируйте: openssl rand -hex 32
|
||||
# ВАЖНО: этот же секрет указывается в панели Remnawave при создании вебхука
|
||||
REMNAWAVE_WEBHOOK_SECRET=
|
||||
# Уведомления администраторам о потере/восстановлении связи с нодами
|
||||
# false = не отправлять события node.connection_lost / node.connection_restored
|
||||
REMNAWAVE_WEBHOOK_NOTIFY_NODE_CONNECTION_STATUS=true
|
||||
|
||||
# ===== УВЕДОМЛЕНИЯ ОТ ВЕБХУКОВ (что получают пользователи) =====
|
||||
# Глобальный переключатель уведомлений пользователям от вебхуков
|
||||
@@ -254,18 +244,6 @@ WEBHOOK_NOTIFY_DEVICES=true
|
||||
# - Подходит для продажи готовых пакетов услуг
|
||||
SALES_MODE=tariffs
|
||||
|
||||
# Управление сменой тарифа (для SALES_MODE=tariffs)
|
||||
# UPGRADE / DOWNGRADE:
|
||||
# true / true = все направления разрешены
|
||||
# true / false = только повышение (на более дорогой тариф)
|
||||
# false / true = только понижение (на более дешёвый тариф)
|
||||
# false / false = смена тарифа полностью отключена
|
||||
TARIFF_SWITCH_UPGRADE_ENABLED=true
|
||||
TARIFF_SWITCH_DOWNGRADE_ENABLED=true
|
||||
|
||||
# Сброс привязанных устройств при продлении подписки (однократно при каждом продлении)
|
||||
RESET_DEVICES_ON_RENEWAL=false
|
||||
|
||||
# ===== ТРИАЛ ПОДПИСКА =====
|
||||
TRIAL_DURATION_DAYS=3
|
||||
TRIAL_TRAFFIC_LIMIT_GB=10
|
||||
@@ -393,10 +371,7 @@ REFERRAL_MINIMUM_TOPUP_KOPEKS=10000
|
||||
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000
|
||||
REFERRAL_INVITER_BONUS_KOPEKS=10000
|
||||
REFERRAL_COMMISSION_PERCENT=25
|
||||
# Макс. кол-во платежей реферала, с которых начисляется комиссия (0 = без лимита)
|
||||
REFERRAL_MAX_COMMISSION_PAYMENTS=0
|
||||
# Показывать раздел партнёрки в кабинете
|
||||
REFERRAL_PARTNER_SECTION_VISIBLE=true
|
||||
|
||||
|
||||
# Уведомления
|
||||
REFERRAL_NOTIFICATIONS_ENABLED=true
|
||||
@@ -409,8 +384,6 @@ REFERRAL_WITHDRAWAL_ENABLED=false
|
||||
REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS=50000
|
||||
# Интервал между запросами на вывод (дни)
|
||||
REFERRAL_WITHDRAWAL_COOLDOWN_DAYS=30
|
||||
# Текст-подсказка для поля реквизитов при выводе (пустая строка = стандартный текст)
|
||||
REFERRAL_WITHDRAWAL_REQUISITES_TEXT=
|
||||
# Выводить только реферальный баланс (true) или весь баланс (false)
|
||||
REFERRAL_WITHDRAWAL_ONLY_REFERRAL_BALANCE=true
|
||||
# ID топика для уведомлений о заявках на вывод (0 = основной чат)
|
||||
@@ -515,11 +488,11 @@ YOOKASSA_WEBHOOK_PORT=8082
|
||||
YOOKASSA_MIN_AMOUNT_KOPEKS=5000
|
||||
YOOKASSA_MAX_AMOUNT_KOPEKS=1000000
|
||||
|
||||
# Рекуррентные платежи YooKassa (автосохранение карты для автоплатежей)
|
||||
YOOKASSA_RECURRENT_ENABLED=false
|
||||
# true = карта сохраняется обязательно, false = пользователь решает (чекбокс на стороне YooKassa)
|
||||
YOOKASSA_RECURRENT_REQUIRED=true
|
||||
# Быстрый выбор суммы пополнения через YooKassa
|
||||
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
|
||||
|
||||
# Отключить отображение кнопок выбора суммы пополнения (оставить только ввод вручную)
|
||||
DISABLE_TOPUP_BUTTONS=false
|
||||
# Отключить пополнение баланса через поддержку
|
||||
SUPPORT_TOPUP_ENABLED=true
|
||||
|
||||
@@ -538,7 +511,6 @@ NALOGO_STORAGE_PATH=./nalogo_tokens.json # Путь к файлу с токен
|
||||
NALOGO_QUEUE_CHECK_INTERVAL=300 # Интервал проверки очереди чеков (секунды)
|
||||
NALOGO_QUEUE_RECEIPT_DELAY=3 # Задержка между отправкой чеков (секунды)
|
||||
NALOGO_QUEUE_MAX_ATTEMPTS=10 # Максимум попыток отправки одного чека
|
||||
# NALOGO_PROXY_URL=socks5://127.0.0.1:1080 # SOCKS прокси для nalog.ru (если не задан — используется PROXY_URL)
|
||||
|
||||
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
|
||||
# Эти настройки позволяют изменить описания платежей,
|
||||
@@ -630,7 +602,7 @@ PLATEGA_RETURN_URL=
|
||||
PLATEGA_FAILED_URL=
|
||||
PLATEGA_CURRENCY=RUB
|
||||
# Список ID активных методов из кабинета Platega (через запятую)
|
||||
PLATEGA_ACTIVE_METHODS=2,11,12,13
|
||||
PLATEGA_ACTIVE_METHODS=2,10,11,12,13
|
||||
PLATEGA_MIN_AMOUNT_KOPEKS=100
|
||||
PLATEGA_MAX_AMOUNT_KOPEKS=100000000
|
||||
PLATEGA_WEBHOOK_PATH=/platega-webhook
|
||||
@@ -657,13 +629,6 @@ FREEKASSA_WEBHOOK_PORT=8088
|
||||
FREEKASSA_PAYMENT_SYSTEM_ID=
|
||||
# Использовать API для создания заказов (обязательно для NSPK СБП)
|
||||
FREEKASSA_USE_API=false
|
||||
# Раздельные методы оплаты (отображаются как отдельные кнопки)
|
||||
# СБП (QR код) — i=44
|
||||
FREEKASSA_SBP_ENABLED=false
|
||||
FREEKASSA_SBP_DISPLAY_NAME=СБП (QR код)
|
||||
# Карты РФ — i=36
|
||||
FREEKASSA_CARD_ENABLED=false
|
||||
FREEKASSA_CARD_DISPLAY_NAME=Карта РФ
|
||||
|
||||
# ===== KASSA AI (api.fk.life) =====
|
||||
# Отдельная платёжная система, работает параллельно с Freekassa
|
||||
@@ -682,84 +647,6 @@ KASSA_AI_WEBHOOK_PORT=8089
|
||||
# Способ оплаты: 44 = СБП (QR), 36 = Карты РФ, 43 = SberPay
|
||||
KASSA_AI_PAYMENT_SYSTEM_ID=44
|
||||
|
||||
# ===== RIOPAY (api.riopay.online) =====
|
||||
RIOPAY_ENABLED=false
|
||||
RIOPAY_API_TOKEN=
|
||||
# Ключ для HMAC-SHA512 верификации вебхуков (если не указан, используется RIOPAY_API_TOKEN)
|
||||
RIOPAY_WEBHOOK_SECRET=
|
||||
RIOPAY_DISPLAY_NAME=RioPay
|
||||
RIOPAY_CURRENCY=RUB
|
||||
RIOPAY_MIN_AMOUNT_KOPEKS=10000
|
||||
RIOPAY_MAX_AMOUNT_KOPEKS=100000000
|
||||
RIOPAY_WEBHOOK_PATH=/riopay-webhook
|
||||
# URL для редиректа после оплаты (опционально)
|
||||
RIOPAY_SUCCESS_URL=
|
||||
RIOPAY_FAIL_URL=
|
||||
|
||||
# ===== SEVERPAY (severpay.io) =====
|
||||
SEVERPAY_ENABLED=false
|
||||
# Merchant ID
|
||||
SEVERPAY_MID=
|
||||
# Секретный токен для HMAC-SHA256
|
||||
SEVERPAY_TOKEN=
|
||||
SEVERPAY_DISPLAY_NAME=SeverPay
|
||||
SEVERPAY_CURRENCY=RUB
|
||||
SEVERPAY_MIN_AMOUNT_KOPEKS=10000
|
||||
SEVERPAY_MAX_AMOUNT_KOPEKS=10000000
|
||||
SEVERPAY_WEBHOOK_PATH=/severpay-webhook
|
||||
# URL возврата после оплаты
|
||||
# SEVERPAY_RETURN_URL=
|
||||
# Время жизни платежа в минутах (30-4320)
|
||||
SEVERPAY_LIFETIME=1440
|
||||
|
||||
# ===== PAYPEAR (api.paypear.ru) =====
|
||||
PAYPEAR_ENABLED=false
|
||||
# Shop ID для HTTP Basic Auth
|
||||
PAYPEAR_SHOP_ID=
|
||||
# Secret Key для HTTP Basic Auth
|
||||
PAYPEAR_SECRET_KEY=
|
||||
PAYPEAR_DISPLAY_NAME=PayPear
|
||||
PAYPEAR_CURRENCY=RUB
|
||||
PAYPEAR_MIN_AMOUNT_KOPEKS=10000
|
||||
PAYPEAR_MAX_AMOUNT_KOPEKS=10000000
|
||||
PAYPEAR_WEBHOOK_PATH=/paypear-webhook
|
||||
# URL возврата после оплаты
|
||||
# PAYPEAR_RETURN_URL=
|
||||
# Время жизни платежа в минутах
|
||||
PAYPEAR_PAYMENT_LIFETIME_MINUTES=60
|
||||
|
||||
# ===== ROLLYPAY (rollypay.io) =====
|
||||
ROLLYPAY_ENABLED=false
|
||||
# API ключ (X-API-Key header)
|
||||
ROLLYPAY_API_KEY=
|
||||
# Секрет для HMAC-SHA256 верификации вебхуков
|
||||
ROLLYPAY_SIGNING_SECRET=
|
||||
ROLLYPAY_DISPLAY_NAME=RollyPay
|
||||
ROLLYPAY_CURRENCY=RUB
|
||||
ROLLYPAY_MIN_AMOUNT_KOPEKS=10000
|
||||
ROLLYPAY_MAX_AMOUNT_KOPEKS=10000000
|
||||
ROLLYPAY_WEBHOOK_PATH=/rollypay-webhook
|
||||
# URL возврата после оплаты
|
||||
# ROLLYPAY_RETURN_URL=
|
||||
|
||||
# ===== AURAPAY (aurapay.tech) =====
|
||||
AURAPAY_ENABLED=false
|
||||
# API ключ (X-ApiKey header)
|
||||
AURAPAY_API_KEY=
|
||||
# UUID магазина (X-ShopId header)
|
||||
AURAPAY_SHOP_ID=
|
||||
# Секретный ключ #2 для HMAC-SHA256 верификации вебхуков
|
||||
AURAPAY_SECRET_KEY=
|
||||
AURAPAY_DISPLAY_NAME=AuraPay
|
||||
AURAPAY_CURRENCY=RUB
|
||||
AURAPAY_MIN_AMOUNT_KOPEKS=10000
|
||||
AURAPAY_MAX_AMOUNT_KOPEKS=10000000
|
||||
AURAPAY_WEBHOOK_PATH=/aurapay-webhook
|
||||
# URL возврата после оплаты
|
||||
# AURAPAY_RETURN_URL=
|
||||
# Время жизни инвойса в минутах
|
||||
AURAPAY_PAYMENT_LIFETIME_MINUTES=60
|
||||
|
||||
# ===== WATA =====
|
||||
WATA_ENABLED=false
|
||||
WATA_BASE_URL=https://api.wata.pro
|
||||
@@ -915,6 +802,8 @@ PRICE_ROUNDING_ENABLED=true
|
||||
TZ=Europe/Moscow # или UTC, America/New_York и т.д.
|
||||
|
||||
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
|
||||
# Конфигурация приложений для гайда подключения
|
||||
APP_CONFIG_PATH=app-config.json
|
||||
ENABLE_DEEP_LINKS=true
|
||||
APP_CONFIG_CACHE_TTL=3600
|
||||
|
||||
@@ -956,8 +845,6 @@ VERSION_CHECK_INTERVAL_HOURS=1
|
||||
# ===== ЛОГИРОВАНИЕ =====
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FILE=logs/bot.log
|
||||
# ANSI-цвета в консоли (true — цветной вывод с Rich, false — plain-text)
|
||||
LOG_COLORS=true
|
||||
|
||||
# === Ротация логов ===
|
||||
# Включить новую систему ротации (по умолчанию старое поведение)
|
||||
@@ -986,9 +873,6 @@ DEBUG=false
|
||||
WEBHOOK_URL=
|
||||
WEBHOOK_PATH=/webhook
|
||||
WEBHOOK_SECRET_TOKEN=
|
||||
# IP адрес сервера для setWebhook — Telegram будет использовать его напрямую без DNS резолва домена
|
||||
# Необходимо в регионах где Telegram не может резолвить домены (РФ и др.)
|
||||
# WEBHOOK_IP=
|
||||
WEBHOOK_DROP_PENDING_UPDATES=true
|
||||
WEBHOOK_MAX_QUEUE_SIZE=1024
|
||||
WEBHOOK_WORKERS=4
|
||||
@@ -1029,4 +913,10 @@ WEB_API_TOKEN_HASH_ALGORITHM=sha256
|
||||
# Логирование запросов
|
||||
WEB_API_REQUEST_LOGGING=true
|
||||
|
||||
# Внешний админ-токен (для интеграции с другими ботами/системами)
|
||||
# Токен для доступа через API другого бота
|
||||
# EXTERNAL_ADMIN_TOKEN=
|
||||
# ID бота, от которого принимается токен
|
||||
# EXTERNAL_ADMIN_TOKEN_BOT_ID=
|
||||
|
||||
MINIAPP_STATIC_PATH=miniapp
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 850 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 822 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 76 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB |
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -26,38 +26,36 @@ jobs:
|
||||
- name: Get version info
|
||||
id: version
|
||||
run: |
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
|
||||
echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT
|
||||
|
||||
# Read base version from release-please manifest (single source of truth)
|
||||
BASE_VERSION=$(jq -r '."."' .release-please-manifest.json)
|
||||
|
||||
|
||||
# Определяем версию и теги
|
||||
if [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v${BASE_VERSION}-${SHORT_SHA}"
|
||||
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="v${BASE_VERSION}-dev-${SHORT_SHA}"
|
||||
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="v${BASE_VERSION}-pr-${SHORT_SHA}"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-${SHORT_SHA}"
|
||||
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
|
||||
|
||||
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "tags=$TAGS" >> $GITHUB_OUTPUT
|
||||
echo "should_push=${{ github.event_name != 'pull_request' }}" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
echo "=== Информация о сборке ==="
|
||||
echo "Версия: $VERSION"
|
||||
echo "Коммит: $SHORT_SHA"
|
||||
echo "Коммит: $(git rev-parse --short HEAD)"
|
||||
echo "Теги: $TAGS"
|
||||
echo "Push: ${{ github.event_name != 'pull_request' }}"
|
||||
echo "==========================="
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -42,28 +42,25 @@ jobs:
|
||||
- name: Get version info
|
||||
id: version
|
||||
run: |
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
|
||||
echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
echo "build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT
|
||||
|
||||
# Read base version from release-please manifest (single source of truth)
|
||||
BASE_VERSION=$(jq -r '."."' .release-please-manifest.json)
|
||||
|
||||
|
||||
if [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v${BASE_VERSION}-${SHORT_SHA}"
|
||||
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="v${BASE_VERSION}-dev-${SHORT_SHA}"
|
||||
VERSION="v3.7.0-dev-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v${BASE_VERSION}-pr-${SHORT_SHA}"
|
||||
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
|
||||
|
||||
|
||||
# Определяем, нужно ли пушить образ
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
echo "should_push=false" >> $GITHUB_OUTPUT
|
||||
echo "⚠️ PR - only build without push"
|
||||
|
||||
@@ -10,11 +10,11 @@ jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: astral-sh/setup-uv@v5
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
!uv.lock
|
||||
!requirements.txt
|
||||
!alembic.ini
|
||||
!app-config.json
|
||||
!release-please-config.json
|
||||
!.release-please-manifest.json
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.54.0"
|
||||
".": "3.12.1"
|
||||
}
|
||||
|
||||
-1861
File diff suppressed because it is too large
Load Diff
+32
-19
@@ -197,17 +197,28 @@ async def create_subscription(
|
||||
### Документация кода
|
||||
|
||||
```python
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
pricing = PricingEngine.calculate_renewal_price(
|
||||
subscription=subscription,
|
||||
period_days=30,
|
||||
user=user,
|
||||
)
|
||||
# pricing.final_total — стоимость в копейках
|
||||
# pricing.original_total — цена до скидок
|
||||
# pricing.promo_group_discount — скидка промогруппы
|
||||
# pricing.promo_offer_discount — скидка промо-оффера
|
||||
async def calculate_subscription_price(
|
||||
period_days: int,
|
||||
traffic_gb: int,
|
||||
devices_count: int,
|
||||
servers_count: int
|
||||
) -> int:
|
||||
"""
|
||||
Рассчитывает стоимость подписки.
|
||||
|
||||
Args:
|
||||
period_days: Период подписки в днях
|
||||
traffic_gb: Лимит трафика в ГБ (0 = безлимит)
|
||||
devices_count: Количество устройств
|
||||
servers_count: Количество серверов
|
||||
|
||||
Returns:
|
||||
Стоимость в копейках
|
||||
|
||||
Raises:
|
||||
ValueError: Если переданы некорректные параметры
|
||||
"""
|
||||
# implementation
|
||||
```
|
||||
|
||||
### Обработка ошибок
|
||||
@@ -330,18 +341,20 @@ python main.py
|
||||
### Тестирование компонентов
|
||||
|
||||
```python
|
||||
# tests/services/test_pricing_engine.py
|
||||
# tests/test_subscription_service.py
|
||||
import pytest
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
def test_calculate_renewal_price():
|
||||
pricing = PricingEngine.calculate_renewal_price(
|
||||
subscription=mock_subscription,
|
||||
@pytest.mark.asyncio
|
||||
async def test_calculate_price():
|
||||
price = await SubscriptionService.calculate_subscription_price(
|
||||
period_days=30,
|
||||
user=mock_user,
|
||||
traffic_gb=100,
|
||||
devices_count=3,
|
||||
servers_count=1
|
||||
)
|
||||
assert pricing.final_total > 0
|
||||
assert isinstance(pricing.final_total, int)
|
||||
assert price > 0
|
||||
assert isinstance(price, int)
|
||||
```
|
||||
|
||||
### Integration тесты
|
||||
|
||||
+17
-17
@@ -4,27 +4,27 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.8 /uv /uvx /bin/
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_PYTHON_DOWNLOADS=never
|
||||
COPY requirements.txt .
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
uv sync --frozen --no-dev
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.54.0" # x-release-please-version
|
||||
ARG VERSION="v3.12.1" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
COPY --from=builder /app/.venv /app/.venv
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& apt-get clean
|
||||
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
RUN groupadd -g 1000 app && \
|
||||
useradd -u 1000 -g 1000 -m -s /bin/bash app
|
||||
@@ -33,8 +33,8 @@ WORKDIR /app
|
||||
|
||||
COPY --chown=app:app . .
|
||||
|
||||
RUN mkdir -p logs data uploads/images uploads/videos uploads/thumbnails locales && \
|
||||
chown -R app:app logs data uploads locales
|
||||
RUN mkdir -p logs data && \
|
||||
chown -R app:app /app logs data
|
||||
|
||||
USER app
|
||||
|
||||
@@ -56,7 +56,7 @@ LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
|
||||
org.opencontainers.image.url="https://github.com/fr1ngg/remnawave-bedolaga-telegram-bot" \
|
||||
org.opencontainers.image.vendor="fr1ngg"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')" || exit 1
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
|
||||
@@ -40,22 +40,6 @@ fix: ## Исправить код (ruff check --fix + format)
|
||||
uv run ruff check . --fix
|
||||
uv run ruff format .
|
||||
|
||||
.PHONY: migrate
|
||||
migrate: ## Применить миграции (alembic upgrade head)
|
||||
uv run alembic upgrade head
|
||||
|
||||
.PHONY: migration
|
||||
migration: ## Создать миграцию (usage: make migration m="description")
|
||||
uv run alembic revision --autogenerate -m "$(m)"
|
||||
|
||||
.PHONY: migrate-stamp
|
||||
migrate-stamp: ## Пометить БД как актуальную (для существующих БД)
|
||||
uv run alembic stamp head
|
||||
|
||||
.PHONY: migrate-history
|
||||
migrate-history: ## Показать историю миграций
|
||||
uv run alembic history --verbose
|
||||
|
||||
.PHONY: help
|
||||
help: ## Показать список доступных команд
|
||||
@echo ""
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
script_location = migrations/alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
sqlalchemy.url = postgresql+asyncpg://vpn_user:your_password@localhost:5432/vpn_bot
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
|
||||
+658
@@ -0,0 +1,658 @@
|
||||
{
|
||||
"config": {
|
||||
"additionalLocales": [
|
||||
"ru",
|
||||
"zh",
|
||||
"fa"
|
||||
],
|
||||
"branding": {
|
||||
"name": "Subscription",
|
||||
"logoUrl": "https://raw.githubusercontent.com/Fr1ngg/remnawave-bedolaga-telegram-bot/bf0c1ce711a26fa2f24559e7e4443820e68d758b/assets/bedolaga_app3.svg",
|
||||
"supportUrl": "https://t.me"
|
||||
}
|
||||
},
|
||||
"platforms": {
|
||||
"ios": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
|
||||
"buttonText": {
|
||||
"en": "Open in App Store [EU]",
|
||||
"fa": "باز کردن در App Store [EU]",
|
||||
"ru": "Открыть в App Store [EU]",
|
||||
"zh": "在 App Store 中打开 [EU]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
|
||||
"buttonText": {
|
||||
"en": "Open in App Store [RU]",
|
||||
"fa": "باز کردن در App Store [RU]",
|
||||
"ru": "Открыть в App Store [RU]",
|
||||
"zh": "在 App Store 中打开 [RU]"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
|
||||
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
|
||||
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
|
||||
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below — the app will open and the subscription will be added automatically",
|
||||
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
|
||||
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
|
||||
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "streisand",
|
||||
"name": "Streisand",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "streisand://import/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/ru/app/streisand/id6450534064",
|
||||
"buttonText": {
|
||||
"en": "Open in App Store",
|
||||
"fa": "باز کردن در App Store",
|
||||
"ru": "Открыть в App Store",
|
||||
"zh": "在 App Store 中打开"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
|
||||
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
|
||||
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
|
||||
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below — the app will open and the subscription will be added automatically",
|
||||
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
|
||||
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
|
||||
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shadowrocket",
|
||||
"name": "Shadowrocket",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "sub://",
|
||||
"isNeedBase64Encoding": true,
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/ru/app/shadowrocket/id932747118",
|
||||
"buttonText": {
|
||||
"en": "Open in App Store",
|
||||
"fa": "باز کردن در App Store",
|
||||
"ru": "Открыть в App Store",
|
||||
"zh": "在 App Store 中打开"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
|
||||
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
|
||||
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
|
||||
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below — the app will open and the subscription will be added automatically",
|
||||
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
|
||||
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
|
||||
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"android": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://play.google.com/store/apps/details?id=com.happproxy",
|
||||
"buttonText": {
|
||||
"en": "Open in Google Play",
|
||||
"fa": "باز کردن در Google Play",
|
||||
"ru": "Открыть в Google Play",
|
||||
"zh": "在 Google Play 中打开"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk",
|
||||
"buttonText": {
|
||||
"en": "Download APK",
|
||||
"fa": "دانلود APK",
|
||||
"ru": "Скачать APK",
|
||||
"zh": "下载 APK"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
|
||||
"fa": "صفحه را در Google Play باز کنید و برنامه را نصب کنید. یا برنامه را مستقیماً از فایل APK نصب کنید، اگر Google Play کار نمی کند.",
|
||||
"ru": "Откройте страницу в Google Play и установите приложение. Или установите приложение из APK файла напрямую, если Google Play не работает.",
|
||||
"zh": "在 Google Play 中打开页面并安装应用。如果 Google Play 无法使用,也可以直接从 APK 文件安装应用。"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
|
||||
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
|
||||
"zh": "点击下方按钮添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open the app and connect to the server",
|
||||
"fa": "برنامه را باز کنید و به سرور متصل شوید",
|
||||
"ru": "Откройте приложение и подключитесь к серверу",
|
||||
"zh": "打开应用并连接到服务器"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "clash-meta",
|
||||
"name": "Clash Meta",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "clash://install-config?url=",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://github.com/MetaCubeX/ClashMetaForAndroid/releases/download/v2.11.7/cmfa-2.11.7-meta-universal-release.apk",
|
||||
"buttonText": {
|
||||
"en": "Download APK",
|
||||
"fa": "دانلود APK",
|
||||
"ru": "Скачать APK",
|
||||
"zh": "下载 APK"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://f-droid.org/packages/com.github.metacubex.clash.meta/",
|
||||
"buttonText": {
|
||||
"en": "Open in F-Droid",
|
||||
"fa": "در F-Droid باز کنید",
|
||||
"ru": "Открыть в F-Droid",
|
||||
"zh": "在 F-Droid 中打开"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Download and install Clash Meta APK",
|
||||
"fa": "دانلود و نصب Clash Meta APK",
|
||||
"ru": "Скачайте и установите Clash Meta APK",
|
||||
"zh": "下载并安装 Clash Meta APK"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Tap the button to import configuration",
|
||||
"fa": "برای وارد کردن پیکربندی روی دکمه ضربه بزنید",
|
||||
"ru": "Нажмите кнопку, чтобы импортировать конфигурацию",
|
||||
"zh": "点击按钮导入配置"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open Clash Meta and tap on Connect",
|
||||
"fa": "Clash Meta را باز کنید و روی اتصال ضربه بزنید",
|
||||
"ru": "Откройте Clash Meta и нажмите Подключиться",
|
||||
"zh": "打开 Clash Meta 并点击连接"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"macos": [
|
||||
{
|
||||
"id": "clash-verge",
|
||||
"name": "Clash Verge",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "clash://install-config?url=",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64-setup.exe",
|
||||
"buttonText": {
|
||||
"en": "Windows",
|
||||
"fa": "ویندوز",
|
||||
"ru": "Windows",
|
||||
"zh": "Windows"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS (Intel)",
|
||||
"fa": "مک (اینتل)",
|
||||
"ru": "macOS (Intel)",
|
||||
"zh": "macOS (Intel)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_aarch64.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS (Apple Silicon)",
|
||||
"fa": "مک (Apple Silicon)",
|
||||
"ru": "macOS (Apple Silicon)",
|
||||
"zh": "macOS (Apple Silicon)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases",
|
||||
"buttonText": {
|
||||
"en": "Linux",
|
||||
"fa": "لینوکس",
|
||||
"ru": "Linux",
|
||||
"zh": "Linux"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Choose the version for your device, click the button below and install the app.",
|
||||
"fa": "نسخه مناسب برای دستگاه خود را انتخاب کنید، دکمه زیر را فشار دهید و برنامه را نصب کنید",
|
||||
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение.",
|
||||
"zh": "选择适合您设备的版本,点击下方按钮并安装应用。"
|
||||
}
|
||||
},
|
||||
"additionalBeforeAddSubscriptionStep": {
|
||||
"buttons": [],
|
||||
"description": {
|
||||
"en": "After launching the app, you can change the language in settings. In the left panel, find the gear icon, then navigate to Verge 设置 and select 语言设置.",
|
||||
"fa": "پس از راهاندازی برنامه، میتوانید زبان را در تنظیمات تغییر دهید. در پنل سمت چپ، نماد چرخ دنده را پیدا کنید، سپس به Verge 设置 بروید و 语言设置 را انتخاب کنید.",
|
||||
"ru": "После запуска приложения вы можете сменить язык в настройках. В левой панели найдите иконку шестеренки, далее ориентируйтесь на Verge 设置 и выберите пункт 语言设置.",
|
||||
"zh": "启动应用后,您可以在设置中更改语言。在左侧面板找到齿轮图标,然后导航到 Verge 设置并选择语言设置。"
|
||||
},
|
||||
"title": {
|
||||
"en": "Change language",
|
||||
"fa": "تغییر زبان",
|
||||
"ru": "Смена языка",
|
||||
"zh": "更改语言"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
|
||||
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
|
||||
"zh": "点击下方按钮添加订阅"
|
||||
}
|
||||
},
|
||||
"additionalAfterAddSubscriptionStep": {
|
||||
"buttons": [],
|
||||
"title": {
|
||||
"en": "If the subscription is not added",
|
||||
"fa": "اگر اشتراک در برنامه نصب نشده است",
|
||||
"ru": "Если подписка не добавилась",
|
||||
"zh": "如果订阅未添加"
|
||||
},
|
||||
"description": {
|
||||
"en": "If nothing happens after clicking the button, add the subscription manually. Click the Get Link button in the top right corner of this page, copy the link. In Clash Verge, go to the Profiles section and paste the link in the text field, then click the Import button.",
|
||||
"fa": "اگر پس از کلیک روی دکمه اتفاقی نیفتاد، اشتراک را به صورت دستی اضافه کنید. در گوشه بالا سمت راست این صفحه روی دکمه دریافت لینک کلیک کنید، لینک را کپی کنید. در Clash Verge به بخش پروفایلها بروید و لینک را در فیلد متنی وارد کنید، سپس روی دکمه وارد کردن کلیک کنید.",
|
||||
"ru": "Если после нажатия на кнопку ничего не произошло, добавьте подписку вручную. Нажмите на этой страницу кнопку Получить ссылку в правом верхнем углу, скопируйте ссылку. В Clash Verge перейдите в раздел Профили и вставьте ссылку в текстовое поле, затем нажмите на кнопку Импорт.",
|
||||
"zh": "如果点击按钮后没有反应,请手动添加订阅。点击此页面右上角的获取链接按钮,复制链接。在 Clash Verge 中,转到配置文件部分,将链接粘贴到文本字段中,然后点击导入按钮。"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "You can select a server in the Proxy section, and enable VPN in the Settings section. Set the TUN Mode switch to ON.",
|
||||
"fa": "میتوانید در بخش پروکسی سرور را انتخاب کنید و در بخش تنظیمات VPN را فعال کنید. کلید TUN Mode را در حالت روشن قرار دهید.",
|
||||
"ru": "Выбрать сервер можно в разделе Прокси, включить VPN можно в разделе Настройки. Установите переключатель TUN Mode в положение ВКЛ.",
|
||||
"zh": "您可以在代理部分选择服务器,在设置部分启用 VPN。将 TUN 模式开关设置为开启。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "hiddify",
|
||||
"name": "Hiddify",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "hiddify://import/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Windows-Setup-x64.exe",
|
||||
"buttonText": {
|
||||
"en": "Windows",
|
||||
"fa": "ویندوز",
|
||||
"ru": "Windows",
|
||||
"zh": "Windows"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-MacOS.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS",
|
||||
"fa": "مک",
|
||||
"ru": "macOS",
|
||||
"zh": "macOS"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Linux-x64.AppImage",
|
||||
"buttonText": {
|
||||
"en": "Linux",
|
||||
"fa": "لینوکس",
|
||||
"ru": "Linux",
|
||||
"zh": "Linux"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. If needed, select a different server in the Proxy section",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. در صورت نیاز، سرور دیگری را در بخش پروکسی انتخاب کنید",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. При необходимости выберите другой сервер в разделе Прокси.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接 VPN。如有需要,可在代理部分选择不同的服务器"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
|
||||
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
|
||||
"zh": "点击下方按钮添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, select a different server from the server list.",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"windows": [
|
||||
{
|
||||
"id": "clash-verge",
|
||||
"name": "Clash Verge",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "clash://install-config?url=",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64-setup.exe",
|
||||
"buttonText": {
|
||||
"en": "Windows",
|
||||
"fa": "ویندوز",
|
||||
"ru": "Windows",
|
||||
"zh": "Windows"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS (Intel)",
|
||||
"fa": "مک (اینتل)",
|
||||
"ru": "macOS (Intel)",
|
||||
"zh": "macOS (Intel)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_aarch64.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS (Apple Silicon)",
|
||||
"fa": "مک (Apple Silicon)",
|
||||
"ru": "macOS (Apple Silicon)",
|
||||
"zh": "macOS (Apple Silicon)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases",
|
||||
"buttonText": {
|
||||
"en": "Linux",
|
||||
"fa": "لینوکس",
|
||||
"ru": "Linux",
|
||||
"zh": "Linux"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Choose the version for your device, click the button below and install the app.",
|
||||
"fa": "نسخه مناسب برای دستگاه خود را انتخاب کنید، دکمه زیر را فشار دهید و برنامه را نصب کنید",
|
||||
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение.",
|
||||
"zh": "选择适合您设备的版本,点击下方按钮并安装应用。"
|
||||
}
|
||||
},
|
||||
"additionalBeforeAddSubscriptionStep": {
|
||||
"buttons": [],
|
||||
"description": {
|
||||
"en": "After launching the app, you can change the language in settings. In the left panel, find the gear icon, then navigate to Verge 设置 and select 语言设置.",
|
||||
"fa": "پس از راهاندازی برنامه، میتوانید زبان را در تنظیمات تغییر دهید. در پنل سمت چپ، نماد چرخ دنده را پیدا کنید، سپس به Verge 设置 بروید و 语言设置 را انتخاب کنید.",
|
||||
"ru": "После запуска приложения вы можете сменить язык в настройках. В левой панели найдите иконку шестеренки, далее ориентируйтесь на Verge 设置 и выберите пункт 语言设置.",
|
||||
"zh": "启动应用后,您可以在设置中更改语言。在左侧面板找到齿轮图标,然后导航到 Verge 设置并选择语言设置。"
|
||||
},
|
||||
"title": {
|
||||
"en": "Change language",
|
||||
"fa": "تغییر زبان",
|
||||
"ru": "Смена языка",
|
||||
"zh": "更改语言"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
|
||||
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
|
||||
"zh": "点击下方按钮添加订阅"
|
||||
}
|
||||
},
|
||||
"additionalAfterAddSubscriptionStep": {
|
||||
"buttons": [],
|
||||
"title": {
|
||||
"en": "If the subscription is not added",
|
||||
"fa": "اگر اشتراک در برنامه نصب نشده است",
|
||||
"ru": "Если подписка не добавилась",
|
||||
"zh": "如果订阅未添加"
|
||||
},
|
||||
"description": {
|
||||
"en": "If nothing happens after clicking the button, add the subscription manually. Click the Get Link button in the top right corner of this page, copy the link. In Clash Verge, go to the Profiles section and paste the link in the text field, then click the Import button.",
|
||||
"fa": "اگر پس از کلیک روی دکمه اتفاقی نیفتاد، اشتراک را به صورت دستی اضافه کنید. در گوشه بالا سمت راست این صفحه روی دکمه دریافت لینک کلیک کنید، لینک را کپی کنید. در Clash Verge به بخش پروفایلها بروید و لینک را در فیلد متنی وارد کنید، سپس روی دکمه وارد کردن کلیک کنید.",
|
||||
"ru": "Если после нажатия на кнопку ничего не произошло, добавьте подписку вручную. Нажмите на этой страницу кнопку Получить ссылку в правом верхнем углу, скопируйте ссылку. В Clash Verge перейдите в раздел Профили и вставьте ссылку в текстовое поле, затем нажмите на кнопку Импорт.",
|
||||
"zh": "如果点击按钮后没有反应,请手动添加订阅。点击此页面右上角的获取链接按钮,复制链接。在 Clash Verge 中,转到配置文件部分,将链接粘贴到文本字段中,然后点击导入按钮。"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "You can select a server in the Proxy section, and enable VPN in the Settings section. Set the TUN Mode switch to ON.",
|
||||
"fa": "میتوانید در بخش پروکسی سرور را انتخاب کنید و در بخش تنظیمات VPN را فعال کنید. کلید TUN Mode را در حالت روشن قرار دهید.",
|
||||
"ru": "Выبрать сервер можно в разделе Прокси, включить VPN можно в разделе Настройки. Установите переключатель TUN Mode в положение ВКЛ.",
|
||||
"zh": "您可以在代理部分选择服务器,在设置部分启用 VPN。将 TUN 模式开关设置为开启。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "hiddify",
|
||||
"name": "Hiddify",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "hiddify://import/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Windows-Setup-x64.exe",
|
||||
"buttonText": {
|
||||
"en": "Windows",
|
||||
"fa": "ویندوز",
|
||||
"ru": "Windows",
|
||||
"zh": "Windows"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-MacOS.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS",
|
||||
"fa": "مک",
|
||||
"ru": "macOS",
|
||||
"zh": "macOS"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Linux-x64.AppImage",
|
||||
"buttonText": {
|
||||
"en": "Linux",
|
||||
"fa": "لینوکس",
|
||||
"ru": "Linux",
|
||||
"zh": "Linux"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. If needed, select a different server in the Proxy section",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. در صورت نیاز، سرور دیگری را در بخش پروکسی انتخاب کنید",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. При необходимости выберите другой сервер в разделе Прокси.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接 VPN。如有需要,可在代理部分选择不同的服务器"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
|
||||
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
|
||||
"zh": "点击下方按钮添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, select a different server from the server list.",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"linux": [],
|
||||
"androidTV": [
|
||||
{
|
||||
"id": "new-app-androidtv-1760203310792",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
|
||||
"buttonText": {
|
||||
"en": "Google Play",
|
||||
"ru": "Button TextGoogle Play",
|
||||
"zh": "Button Text",
|
||||
"fa": "Button Text"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
|
||||
"ru": "Откройте страницу в Google Play и установите приложение",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open the app and connect to the server",
|
||||
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"appleTV": [
|
||||
{
|
||||
"id": "new-app-appletv-1760203488851",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
|
||||
"buttonText": {
|
||||
"en": "Google Play",
|
||||
"ru": "Google Play",
|
||||
"zh": "Button Text",
|
||||
"fa": "Button Text"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
|
||||
"ru": "Откройте страницу в Google Play и установите приложение",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open the app and connect to the server",
|
||||
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+16
-62
@@ -45,7 +45,6 @@ from app.handlers.admin import (
|
||||
referrals as admin_referrals,
|
||||
remnawave as admin_remnawave,
|
||||
reports as admin_reports,
|
||||
required_channels as admin_required_channels,
|
||||
rules as admin_rules,
|
||||
servers as admin_servers,
|
||||
statistics as admin_statistics,
|
||||
@@ -59,15 +58,11 @@ from app.handlers.admin import (
|
||||
users as admin_users,
|
||||
welcome_text as admin_welcome_text,
|
||||
)
|
||||
from app.handlers.channel_member import register_handlers as register_channel_member_handlers
|
||||
from app.handlers.gift_activation import register_handlers as register_gift_activation_handlers
|
||||
from app.handlers.stars_payments import register_stars_handlers
|
||||
from app.middlewares.auth import AuthMiddleware
|
||||
from app.middlewares.blacklist import BlacklistMiddleware
|
||||
from app.middlewares.button_stats import ButtonStatsMiddleware
|
||||
from app.middlewares.chat_type_filter import ChatTypeFilterMiddleware
|
||||
from app.middlewares.context_binding import ContextVarsMiddleware
|
||||
from app.middlewares.display_name_restriction import DisplayNameRestrictionMiddleware
|
||||
from app.middlewares.global_error import GlobalErrorMiddleware
|
||||
from app.middlewares.logging import LoggingMiddleware
|
||||
from app.middlewares.maintenance import MaintenanceMiddleware
|
||||
@@ -97,21 +92,10 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
except Exception as e:
|
||||
logger.warning('Кеш не инициализирован', error=e)
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
|
||||
bot = create_bot()
|
||||
|
||||
proxy_url = settings.get_proxy_url()
|
||||
nalogo_proxy_url = settings.get_nalogo_proxy_url()
|
||||
|
||||
if proxy_url or nalogo_proxy_url:
|
||||
from app.utils.proxy import mask_proxy_url
|
||||
|
||||
if proxy_url:
|
||||
logger.info('Proxy configured', proxy_url=mask_proxy_url(proxy_url))
|
||||
if nalogo_proxy_url:
|
||||
source = 'NALOGO_PROXY_URL' if settings.NALOGO_PROXY_URL else 'PROXY_URL (fallback)'
|
||||
logger.info('Nalogo proxy configured', proxy_url=mask_proxy_url(nalogo_proxy_url), source=source)
|
||||
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
|
||||
maintenance_service.set_bot(bot)
|
||||
logger.info('Бот установлен в maintenance_service')
|
||||
@@ -131,23 +115,19 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
dp.message.middleware(ContextVarsMiddleware())
|
||||
dp.callback_query.middleware(ContextVarsMiddleware())
|
||||
dp.pre_checkout_query.middleware(ContextVarsMiddleware())
|
||||
chat_type_filter = ChatTypeFilterMiddleware()
|
||||
dp.message.middleware(chat_type_filter)
|
||||
dp.callback_query.middleware(chat_type_filter)
|
||||
dp.message.middleware(LoggingMiddleware())
|
||||
dp.callback_query.middleware(LoggingMiddleware())
|
||||
dp.message.middleware(GlobalErrorMiddleware())
|
||||
dp.callback_query.middleware(GlobalErrorMiddleware())
|
||||
dp.pre_checkout_query.middleware(GlobalErrorMiddleware())
|
||||
dp.message.middleware(LoggingMiddleware())
|
||||
dp.callback_query.middleware(LoggingMiddleware())
|
||||
dp.message.middleware(MaintenanceMiddleware())
|
||||
dp.callback_query.middleware(MaintenanceMiddleware())
|
||||
blacklist_middleware = BlacklistMiddleware()
|
||||
dp.message.middleware(blacklist_middleware)
|
||||
dp.callback_query.middleware(blacklist_middleware)
|
||||
dp.pre_checkout_query.middleware(blacklist_middleware)
|
||||
throttling_middleware = ThrottlingMiddleware()
|
||||
dp.message.middleware(throttling_middleware)
|
||||
dp.callback_query.middleware(throttling_middleware)
|
||||
dp.message.middleware(ThrottlingMiddleware())
|
||||
dp.callback_query.middleware(ThrottlingMiddleware())
|
||||
|
||||
# Middleware для автоматического логирования кликов по кнопкам
|
||||
if settings.MENU_LAYOUT_ENABLED:
|
||||
@@ -155,20 +135,20 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
dp.callback_query.middleware(button_stats_middleware)
|
||||
logger.info('📊 ButtonStatsMiddleware активирован')
|
||||
|
||||
from app.middlewares.channel_checker import ChannelCheckerMiddleware
|
||||
if settings.CHANNEL_IS_REQUIRED_SUB:
|
||||
from app.middlewares.channel_checker import ChannelCheckerMiddleware
|
||||
|
||||
channel_checker = ChannelCheckerMiddleware()
|
||||
dp.message.middleware(channel_checker)
|
||||
dp.callback_query.middleware(channel_checker)
|
||||
channel_checker_middleware = ChannelCheckerMiddleware()
|
||||
dp.message.middleware(channel_checker_middleware)
|
||||
dp.callback_query.middleware(channel_checker_middleware)
|
||||
logger.info('🔒 Обязательная подписка включена - ChannelCheckerMiddleware активирован')
|
||||
else:
|
||||
logger.info('🔓 Обязательная подписка отключена - ChannelCheckerMiddleware не зарегистрирован')
|
||||
dp.message.middleware(AuthMiddleware())
|
||||
dp.callback_query.middleware(AuthMiddleware())
|
||||
dp.pre_checkout_query.middleware(AuthMiddleware())
|
||||
display_name_restriction = DisplayNameRestrictionMiddleware()
|
||||
dp.message.middleware(display_name_restriction)
|
||||
dp.callback_query.middleware(display_name_restriction)
|
||||
dp.message.middleware(SubscriptionStatusMiddleware())
|
||||
dp.callback_query.middleware(SubscriptionStatusMiddleware())
|
||||
dp.pre_checkout_query.middleware(SubscriptionStatusMiddleware())
|
||||
start.register_handlers(dp)
|
||||
menu.register_handlers(dp)
|
||||
subscription.register_handlers(dp)
|
||||
@@ -214,9 +194,6 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_bulk_ban.register_bulk_ban_handlers(dp)
|
||||
admin_blacklist.register_blacklist_handlers(dp)
|
||||
admin_blocked_users.register_handlers(dp)
|
||||
admin_required_channels.register_handlers(dp)
|
||||
register_channel_member_handlers(dp)
|
||||
register_gift_activation_handlers(dp)
|
||||
common.register_handlers(dp)
|
||||
register_stars_handlers(dp)
|
||||
user_contests.register_handlers(dp)
|
||||
@@ -264,7 +241,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
elif settings.is_cabinet_mode():
|
||||
logger.info('🏠 Режим Cabinet активен, базовый URL', MINIAPP_CUSTOM_URL=settings.MINIAPP_CUSTOM_URL)
|
||||
|
||||
# Load per-section button styles cache and menu layout cache
|
||||
# Load per-section button styles cache
|
||||
if settings.is_cabinet_mode():
|
||||
try:
|
||||
from app.utils.button_styles_cache import load_button_styles_cache
|
||||
@@ -273,35 +250,12 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
except Exception as e:
|
||||
logger.warning('Failed to load button styles cache', error=e)
|
||||
|
||||
try:
|
||||
from app.utils.menu_layout_cache import load_menu_layout_cache
|
||||
|
||||
await load_menu_layout_cache()
|
||||
except Exception as e:
|
||||
logger.warning('Failed to load menu layout cache', error=e)
|
||||
|
||||
try:
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
await remnawave_retry_queue.start()
|
||||
logger.info('RemnaWave retry queue запущен')
|
||||
except Exception as e:
|
||||
logger.error('Ошибка запуска RemnaWave retry queue', error=e)
|
||||
|
||||
logger.info('Бот успешно настроен')
|
||||
|
||||
return bot, dp
|
||||
|
||||
|
||||
async def shutdown_bot():
|
||||
try:
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
await remnawave_retry_queue.stop()
|
||||
logger.info('RemnaWave retry queue остановлен')
|
||||
except Exception as e:
|
||||
logger.error('Ошибка остановки RemnaWave retry queue', error=e)
|
||||
|
||||
try:
|
||||
await maintenance_service.stop_monitoring()
|
||||
logger.info('Мониторинг техработ остановлен')
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
"""Factory for creating Bot instances with proxy and custom API server support."""
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def create_bot(token: str | None = None, **kwargs) -> Bot:
|
||||
"""Create a Bot instance with SOCKS5 proxy and/or custom Telegram API server."""
|
||||
proxy_url = settings.get_proxy_url()
|
||||
telegram_api_url = settings.get_telegram_api_url()
|
||||
session = None
|
||||
if proxy_url or telegram_api_url:
|
||||
from aiogram.client.session.aiohttp import AiohttpSession
|
||||
from aiogram.client.telegram import TelegramAPIServer
|
||||
|
||||
session_kwargs: dict = {}
|
||||
if proxy_url:
|
||||
session_kwargs['proxy'] = proxy_url
|
||||
if telegram_api_url:
|
||||
session_kwargs['api'] = TelegramAPIServer.from_base(telegram_api_url)
|
||||
|
||||
session = AiohttpSession(**session_kwargs)
|
||||
|
||||
kwargs.setdefault('default', DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
return Bot(token=token or settings.BOT_TOKEN, session=session, **kwargs)
|
||||
@@ -2,24 +2,21 @@
|
||||
|
||||
from .jwt_handler import (
|
||||
create_access_token,
|
||||
create_auto_login_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
get_token_payload,
|
||||
)
|
||||
from .password_utils import hash_password, verify_password
|
||||
from .telegram_auth import validate_telegram_init_data, validate_telegram_login_widget, validate_telegram_oidc_token
|
||||
from .telegram_auth import validate_telegram_init_data, validate_telegram_login_widget
|
||||
|
||||
|
||||
__all__ = [
|
||||
'create_access_token',
|
||||
'create_auto_login_token',
|
||||
'create_refresh_token',
|
||||
'decode_token',
|
||||
'get_token_payload',
|
||||
'hash_password',
|
||||
'validate_telegram_init_data',
|
||||
'validate_telegram_login_widget',
|
||||
'validate_telegram_oidc_token',
|
||||
'verify_password',
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Email verification token generation and validation."""
|
||||
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.config import settings
|
||||
|
||||
@@ -24,7 +24,7 @@ def get_email_change_expires_at() -> datetime:
|
||||
Datetime when the email change code expires
|
||||
"""
|
||||
minutes = settings.get_cabinet_email_change_code_expire_minutes()
|
||||
return datetime.now(UTC) + timedelta(minutes=minutes)
|
||||
return datetime.utcnow() + timedelta(minutes=minutes)
|
||||
|
||||
|
||||
def generate_verification_token() -> str:
|
||||
@@ -55,7 +55,7 @@ def get_verification_expires_at() -> datetime:
|
||||
Datetime when the verification token expires
|
||||
"""
|
||||
hours = settings.get_cabinet_email_verification_expire_hours()
|
||||
return datetime.now(UTC) + timedelta(hours=hours)
|
||||
return datetime.utcnow() + timedelta(hours=hours)
|
||||
|
||||
|
||||
def get_password_reset_expires_at() -> datetime:
|
||||
@@ -66,7 +66,7 @@ def get_password_reset_expires_at() -> datetime:
|
||||
Datetime when the password reset token expires
|
||||
"""
|
||||
hours = settings.get_cabinet_password_reset_expire_hours()
|
||||
return datetime.now(UTC) + timedelta(hours=hours)
|
||||
return datetime.utcnow() + timedelta(hours=hours)
|
||||
|
||||
|
||||
def is_token_expired(expires_at: datetime | None) -> bool:
|
||||
@@ -81,4 +81,4 @@ def is_token_expired(expires_at: datetime | None) -> bool:
|
||||
"""
|
||||
if expires_at is None:
|
||||
return True
|
||||
return datetime.now(UTC) > expires_at
|
||||
return datetime.utcnow() > expires_at
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""JWT token handling for cabinet authentication."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
@@ -11,49 +11,31 @@ from app.config import settings
|
||||
JWT_ALGORITHM = 'HS256'
|
||||
|
||||
|
||||
def create_access_token(
|
||||
user_id: int,
|
||||
telegram_id: int | None = None,
|
||||
*,
|
||||
permissions: list[str] | None = None,
|
||||
roles: list[str] | None = None,
|
||||
role_level: int = 0,
|
||||
) -> str:
|
||||
def create_access_token(user_id: int, telegram_id: int | None = None) -> str:
|
||||
"""
|
||||
Create a short-lived access token.
|
||||
|
||||
Args:
|
||||
user_id: Database user ID
|
||||
telegram_id: Telegram user ID (optional for email-only users)
|
||||
permissions: RBAC permission strings to embed in token
|
||||
roles: Role names to embed in token
|
||||
role_level: Maximum role level (0 = no special level)
|
||||
|
||||
Returns:
|
||||
Encoded JWT access token
|
||||
"""
|
||||
expire_minutes = settings.get_cabinet_access_token_expire_minutes()
|
||||
expires = datetime.now(UTC) + timedelta(minutes=expire_minutes)
|
||||
expires = datetime.utcnow() + timedelta(minutes=expire_minutes)
|
||||
|
||||
payload = {
|
||||
'sub': str(user_id),
|
||||
'type': 'access',
|
||||
'exp': expires,
|
||||
'iat': datetime.now(UTC),
|
||||
'iat': datetime.utcnow(),
|
||||
}
|
||||
|
||||
# Добавляем telegram_id только если он есть
|
||||
if telegram_id is not None:
|
||||
payload['telegram_id'] = telegram_id
|
||||
|
||||
# RBAC data — only include when provided to keep token compact
|
||||
if permissions is not None:
|
||||
payload['permissions'] = permissions
|
||||
if roles is not None:
|
||||
payload['roles'] = roles
|
||||
if role_level > 0:
|
||||
payload['role_level'] = role_level
|
||||
|
||||
secret = settings.get_cabinet_jwt_secret()
|
||||
return jwt.encode(payload, secret, algorithm=JWT_ALGORITHM)
|
||||
|
||||
@@ -69,13 +51,13 @@ def create_refresh_token(user_id: int) -> str:
|
||||
Encoded JWT refresh token
|
||||
"""
|
||||
expire_days = settings.get_cabinet_refresh_token_expire_days()
|
||||
expires = datetime.now(UTC) + timedelta(days=expire_days)
|
||||
expires = datetime.utcnow() + timedelta(days=expire_days)
|
||||
|
||||
payload = {
|
||||
'sub': str(user_id),
|
||||
'type': 'refresh',
|
||||
'exp': expires,
|
||||
'iat': datetime.now(UTC),
|
||||
'iat': datetime.utcnow(),
|
||||
}
|
||||
|
||||
secret = settings.get_cabinet_jwt_secret()
|
||||
@@ -123,19 +105,7 @@ def get_token_payload(token: str, expected_type: str = 'access') -> dict[str, An
|
||||
return payload
|
||||
|
||||
|
||||
def create_auto_login_token(user_id: int, ttl_hours: int = 72) -> str:
|
||||
"""Short-lived JWT for auto-login from guest purchase success page."""
|
||||
expires = datetime.now(UTC) + timedelta(hours=ttl_hours)
|
||||
payload = {
|
||||
'sub': str(user_id),
|
||||
'type': 'auto_login',
|
||||
'exp': expires,
|
||||
'iat': datetime.now(UTC),
|
||||
}
|
||||
return jwt.encode(payload, settings.get_cabinet_jwt_secret(), algorithm=JWT_ALGORITHM)
|
||||
|
||||
|
||||
def get_refresh_token_expires_at() -> datetime:
|
||||
"""Get the expiration datetime for a new refresh token."""
|
||||
expire_days = settings.get_cabinet_refresh_token_expire_days()
|
||||
return datetime.now(UTC) + timedelta(days=expire_days)
|
||||
return datetime.utcnow() + timedelta(days=expire_days)
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
"""Temporary merge token management for account linking.
|
||||
|
||||
Stores short-lived tokens in Redis so the user can confirm merging
|
||||
two cabinet accounts (primary absorbs secondary) via a separate
|
||||
confirmation endpoint.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
from app.utils.cache import cache, cache_key
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
MERGE_TOKEN_TTL_SECONDS = 1800 # 30 minutes
|
||||
MERGE_TOKEN_PREFIX = 'account_merge'
|
||||
|
||||
|
||||
async def create_merge_token(
|
||||
primary_user_id: int,
|
||||
secondary_user_id: int,
|
||||
provider: str,
|
||||
provider_id: str,
|
||||
) -> str:
|
||||
"""Generate a merge token and store its payload in Redis.
|
||||
|
||||
The token is a one-time confirmation handle: whoever presents it
|
||||
within ``MERGE_TOKEN_TTL_SECONDS`` can execute the account merge.
|
||||
|
||||
Returns the raw token string (URL-safe base64, 32 bytes of entropy).
|
||||
Raises ``RuntimeError`` if Redis write fails.
|
||||
"""
|
||||
token = secrets.token_urlsafe(32)
|
||||
value: dict[str, Any] = {
|
||||
'primary_user_id': primary_user_id,
|
||||
'secondary_user_id': secondary_user_id,
|
||||
'provider': provider,
|
||||
'provider_id': provider_id,
|
||||
'created_at': datetime.now(UTC).isoformat(),
|
||||
}
|
||||
key = cache_key(MERGE_TOKEN_PREFIX, token)
|
||||
stored = await cache.set(key, value, expire=MERGE_TOKEN_TTL_SECONDS)
|
||||
if not stored:
|
||||
logger.error(
|
||||
'Failed to store merge token in Redis',
|
||||
primary_user_id=primary_user_id,
|
||||
secondary_user_id=secondary_user_id,
|
||||
provider=provider,
|
||||
)
|
||||
raise RuntimeError('Failed to store merge token')
|
||||
|
||||
logger.info(
|
||||
'Merge token created',
|
||||
primary_user_id=primary_user_id,
|
||||
secondary_user_id=secondary_user_id,
|
||||
provider=provider,
|
||||
provider_id=provider_id,
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
async def get_merge_token_data(token: str) -> dict[str, Any] | None:
|
||||
"""Read merge token payload *without* consuming it.
|
||||
|
||||
Intended for preview / confirmation screens where the user sees
|
||||
what will happen before they press "Confirm".
|
||||
|
||||
Returns ``None`` when the token is expired, missing, or malformed.
|
||||
"""
|
||||
key = cache_key(MERGE_TOKEN_PREFIX, token)
|
||||
data: Any = await cache.get(key)
|
||||
if data is None or not isinstance(data, dict):
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
async def consume_merge_token(token: str) -> dict[str, Any] | None:
|
||||
"""Atomically read and delete a merge token (GETDEL).
|
||||
|
||||
This prevents double-merge race conditions: only the first caller
|
||||
that reaches Redis will get the payload; every subsequent attempt
|
||||
receives ``None``.
|
||||
|
||||
Returns the stored dict or ``None`` if already consumed / expired.
|
||||
"""
|
||||
key = cache_key(MERGE_TOKEN_PREFIX, token)
|
||||
data: Any = await cache.getdel(key)
|
||||
if data is None or not isinstance(data, dict):
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
'Merge token consumed',
|
||||
primary_user_id=data.get('primary_user_id'),
|
||||
secondary_user_id=data.get('secondary_user_id'),
|
||||
provider=data.get('provider'),
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
_MAX_MERGE_RESTORE_ATTEMPTS = 3
|
||||
|
||||
|
||||
async def restore_merge_token(token: str, data: dict[str, Any]) -> bool:
|
||||
"""Re-store a consumed merge token so the user can retry after a DB failure.
|
||||
|
||||
Uses the remaining TTL based on the original ``created_at``.
|
||||
Uses SETNX to avoid overwriting a fresh token.
|
||||
Caps restore attempts to prevent infinite retry cycles.
|
||||
Returns ``True`` if restored, ``False`` if exhausted or Redis write failed.
|
||||
"""
|
||||
restore_count = data.get('_restore_count', 0) + 1
|
||||
if restore_count > _MAX_MERGE_RESTORE_ATTEMPTS:
|
||||
logger.warning(
|
||||
'Merge token exhausted restore attempts',
|
||||
primary_user_id=data.get('primary_user_id'),
|
||||
secondary_user_id=data.get('secondary_user_id'),
|
||||
restore_count=restore_count,
|
||||
)
|
||||
return False
|
||||
|
||||
# Shallow copy to avoid mutating the caller's dict
|
||||
data = {**data, '_restore_count': restore_count}
|
||||
|
||||
created_at_str: str = data.get('created_at', '')
|
||||
try:
|
||||
created_at = datetime.fromisoformat(created_at_str)
|
||||
if created_at.tzinfo is None:
|
||||
created_at = created_at.replace(tzinfo=UTC)
|
||||
elapsed = (datetime.now(UTC) - created_at).total_seconds()
|
||||
remaining_ttl = max(1, min(int(MERGE_TOKEN_TTL_SECONDS - elapsed), MERGE_TOKEN_TTL_SECONDS))
|
||||
except (ValueError, TypeError):
|
||||
remaining_ttl = 60 # brief retry window — fail closed
|
||||
|
||||
key = cache_key(MERGE_TOKEN_PREFIX, token)
|
||||
stored = await cache.setnx(key, data, expire=remaining_ttl)
|
||||
if stored:
|
||||
logger.info(
|
||||
'Merge token restored after failed merge',
|
||||
primary_user_id=data.get('primary_user_id'),
|
||||
secondary_user_id=data.get('secondary_user_id'),
|
||||
remaining_ttl=remaining_ttl,
|
||||
restore_count=restore_count,
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
'Failed to restore merge token to Redis (key may already exist)',
|
||||
primary_user_id=data.get('primary_user_id'),
|
||||
secondary_user_id=data.get('secondary_user_id'),
|
||||
)
|
||||
return bool(stored)
|
||||
@@ -1,7 +1,5 @@
|
||||
"""OAuth 2.0 provider implementations for cabinet authentication."""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import secrets
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, TypedDict
|
||||
@@ -35,7 +33,7 @@ class OAuthTokenResponse(TypedDict, total=False):
|
||||
expires_in: int
|
||||
refresh_token: str
|
||||
scope: str
|
||||
# Provider-specific extra fields (optional)
|
||||
# VK-specific: email and user_id come in token response
|
||||
email: str
|
||||
user_id: int
|
||||
|
||||
@@ -69,19 +67,15 @@ class DiscordUserInfoResponse(TypedDict, total=False):
|
||||
avatar: str
|
||||
|
||||
|
||||
class VKIDUserData(TypedDict, total=False):
|
||||
"""VK ID /oauth2/user_info response user object."""
|
||||
|
||||
user_id: str
|
||||
class VKUserInfoItem(TypedDict, total=False):
|
||||
id: int
|
||||
first_name: str
|
||||
last_name: str
|
||||
phone: str
|
||||
avatar: str
|
||||
email: str
|
||||
photo_200: str
|
||||
|
||||
|
||||
class VKIDUserInfoResponse(TypedDict, total=False):
|
||||
user: VKIDUserData
|
||||
class VKUserInfoResponse(TypedDict, total=False):
|
||||
response: list[VKUserInfoItem]
|
||||
|
||||
|
||||
# --- Models ---
|
||||
@@ -103,45 +97,23 @@ class OAuthUserInfo(BaseModel):
|
||||
# --- CSRF state management (Redis) ---
|
||||
|
||||
|
||||
async def generate_oauth_state(provider: str, extra_data: dict[str, str] | None = None) -> str:
|
||||
"""Generate a CSRF state token for OAuth flow.
|
||||
|
||||
Stores provider name and optional extra data (e.g., PKCE code_verifier) in Redis with TTL.
|
||||
Keys prefixed with '_' are ephemeral and NOT stored in Redis (e.g., _code_challenge).
|
||||
CacheService handles JSON serialization internally.
|
||||
"""
|
||||
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)
|
||||
value: dict[str, Any] = {'provider': provider}
|
||||
if extra_data:
|
||||
# Filter out ephemeral keys (prefixed with '_') — they're only needed for the URL
|
||||
value.update({k: v for k, v in extra_data.items() if not k.startswith('_')})
|
||||
stored = await cache.set(cache_key('oauth_state', state), value, expire=STATE_TTL_SECONDS)
|
||||
if not stored:
|
||||
logger.error('Failed to store OAuth state in Redis')
|
||||
raise RuntimeError('Failed to store OAuth state')
|
||||
await cache.set(cache_key('oauth_state', state), provider, expire=STATE_TTL_SECONDS)
|
||||
return state
|
||||
|
||||
|
||||
async def validate_oauth_state(state: str, provider: str | None = None) -> dict[str, Any] | None:
|
||||
"""Validate and consume a CSRF state token from Redis.
|
||||
|
||||
Uses atomic GETDEL to prevent TOCTOU race conditions.
|
||||
Returns the stored data dict (with 'provider' key + any extra data) or None if invalid.
|
||||
|
||||
Args:
|
||||
state: The state token to validate.
|
||||
provider: If provided, verifies it matches the stored provider.
|
||||
If None, skips provider check (used for server-complete flow).
|
||||
"""
|
||||
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)
|
||||
data: Any = await cache.getdel(key)
|
||||
if data is None:
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
if provider is not None and data.get('provider') != provider:
|
||||
return None
|
||||
return data
|
||||
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 ---
|
||||
@@ -158,28 +130,13 @@ class OAuthProvider(ABC):
|
||||
self.client_secret = client_secret
|
||||
self.redirect_uri = redirect_uri
|
||||
|
||||
def prepare_auth_state(self) -> dict[str, str]:
|
||||
"""Return extra data to store with OAuth state (e.g., PKCE code_verifier).
|
||||
|
||||
Override in providers that need PKCE or other state-stored data.
|
||||
The returned dict is stored in Redis alongside the state token
|
||||
and passed back via validate_oauth_state().
|
||||
"""
|
||||
return {}
|
||||
@abstractmethod
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
"""Build the authorization URL for the provider."""
|
||||
|
||||
@abstractmethod
|
||||
def get_authorization_url(self, state: str, **kwargs: Any) -> str:
|
||||
"""Build the authorization URL for the provider.
|
||||
|
||||
kwargs may contain extra data from prepare_auth_state() (e.g., code_challenge).
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def exchange_code(self, code: str, **kwargs: Any) -> OAuthTokenResponse:
|
||||
"""Exchange authorization code for tokens.
|
||||
|
||||
kwargs may contain provider-specific params (e.g., device_id, code_verifier for VK).
|
||||
"""
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
"""Exchange authorization code for tokens."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
|
||||
@@ -194,7 +151,7 @@ class GoogleProvider(OAuthProvider):
|
||||
TOKEN_URL = 'https://oauth2.googleapis.com/token'
|
||||
USERINFO_URL = 'https://www.googleapis.com/oauth2/v3/userinfo'
|
||||
|
||||
def get_authorization_url(self, state: str, **kwargs: Any) -> str:
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
params: dict[str, str] = {
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
@@ -207,7 +164,7 @@ class GoogleProvider(OAuthProvider):
|
||||
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
|
||||
return str(request.url)
|
||||
|
||||
async def exchange_code(self, code: str, **kwargs: Any) -> OAuthTokenResponse:
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
self.TOKEN_URL,
|
||||
@@ -252,7 +209,7 @@ class YandexProvider(OAuthProvider):
|
||||
TOKEN_URL = 'https://oauth.yandex.com/token'
|
||||
USERINFO_URL = 'https://login.yandex.ru/info'
|
||||
|
||||
def get_authorization_url(self, state: str, **kwargs: Any) -> str:
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
params: dict[str, str] = {
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
@@ -264,7 +221,7 @@ class YandexProvider(OAuthProvider):
|
||||
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
|
||||
return str(request.url)
|
||||
|
||||
async def exchange_code(self, code: str, **kwargs: Any) -> OAuthTokenResponse:
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
self.TOKEN_URL,
|
||||
@@ -318,7 +275,7 @@ class DiscordProvider(OAuthProvider):
|
||||
TOKEN_URL = 'https://discord.com/api/oauth2/token'
|
||||
USERINFO_URL = 'https://discord.com/api/v10/users/@me'
|
||||
|
||||
def get_authorization_url(self, state: str, **kwargs: Any) -> str:
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
params: dict[str, str] = {
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
@@ -330,7 +287,7 @@ class DiscordProvider(OAuthProvider):
|
||||
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
|
||||
return str(request.url)
|
||||
|
||||
async def exchange_code(self, code: str, **kwargs: Any) -> OAuthTokenResponse:
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
self.TOKEN_URL,
|
||||
@@ -372,72 +329,35 @@ class DiscordProvider(OAuthProvider):
|
||||
|
||||
|
||||
class VKProvider(OAuthProvider):
|
||||
"""VK ID OAuth 2.1 provider (id.vk.ru).
|
||||
|
||||
Uses OAuth 2.1 with mandatory PKCE (S256).
|
||||
Old oauth.vk.com endpoints deprecated since September 30, 2025.
|
||||
"""
|
||||
|
||||
name = 'vk'
|
||||
display_name = 'VK'
|
||||
|
||||
AUTHORIZE_URL = 'https://id.vk.ru/authorize'
|
||||
TOKEN_URL = 'https://id.vk.ru/oauth2/auth'
|
||||
USERINFO_URL = 'https://id.vk.ru/oauth2/user_info'
|
||||
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'
|
||||
|
||||
@staticmethod
|
||||
def _generate_pkce() -> tuple[str, str]:
|
||||
"""Generate PKCE code_verifier and code_challenge (S256)."""
|
||||
code_verifier = secrets.token_urlsafe(64)
|
||||
digest = hashlib.sha256(code_verifier.encode('ascii')).digest()
|
||||
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b'=').decode('ascii')
|
||||
return code_verifier, code_challenge
|
||||
|
||||
def prepare_auth_state(self) -> dict[str, str]:
|
||||
"""Generate PKCE pair. code_verifier stored in Redis, code_challenge only goes to URL."""
|
||||
code_verifier, code_challenge = self._generate_pkce()
|
||||
# code_challenge is ephemeral — only needed for the authorization URL,
|
||||
# not stored in Redis (code_verifier is the secret used during token exchange)
|
||||
return {
|
||||
'code_verifier': code_verifier,
|
||||
'_code_challenge': code_challenge,
|
||||
}
|
||||
|
||||
def get_authorization_url(self, state: str, **kwargs: Any) -> str:
|
||||
code_challenge: str = kwargs.get('_code_challenge', '')
|
||||
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': 'vkid.personal_info email',
|
||||
'scope': 'email',
|
||||
'state': state,
|
||||
'code_challenge': code_challenge,
|
||||
'code_challenge_method': 'S256',
|
||||
'v': self.API_VERSION,
|
||||
}
|
||||
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
|
||||
return str(request.url)
|
||||
|
||||
async def exchange_code(self, code: str, **kwargs: Any) -> OAuthTokenResponse:
|
||||
device_id: str = kwargs.get('device_id', '')
|
||||
code_verifier: str = kwargs.get('code_verifier', '')
|
||||
state: str = kwargs.get('state', '')
|
||||
|
||||
if not device_id:
|
||||
raise ValueError('device_id is required for VK ID token exchange')
|
||||
if not code_verifier:
|
||||
raise ValueError('code_verifier is required for VK ID token exchange')
|
||||
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
response = await client.get(
|
||||
self.TOKEN_URL,
|
||||
data={
|
||||
'grant_type': 'authorization_code',
|
||||
params={
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'code': code,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'client_id': self.client_id,
|
||||
'device_id': device_id,
|
||||
'code_verifier': code_verifier,
|
||||
'state': state,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
@@ -446,37 +366,33 @@ class VKProvider(OAuthProvider):
|
||||
|
||||
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
|
||||
access_token = token_data['access_token']
|
||||
user_id: int | None = token_data.get('user_id')
|
||||
# VK returns email in token response, not in userinfo
|
||||
email: str | None = token_data.get('email')
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
response = await client.get(
|
||||
self.USERINFO_URL,
|
||||
data={
|
||||
params={
|
||||
'access_token': access_token,
|
||||
'client_id': self.client_id,
|
||||
'fields': 'photo_200',
|
||||
'v': self.API_VERSION,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: VKIDUserInfoResponse = response.json()
|
||||
data: VKUserInfoResponse = response.json()
|
||||
|
||||
user_data = data.get('user')
|
||||
if not user_data:
|
||||
raise ValueError('VK ID response missing user data')
|
||||
|
||||
user_id = user_data.get('user_id')
|
||||
if not user_id:
|
||||
raise ValueError('VK ID response missing user_id')
|
||||
|
||||
# VK ID returns email only if 'email' scope was granted and user has a verified email
|
||||
email: str | None = user_data.get('email') or None
|
||||
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),
|
||||
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('avatar'),
|
||||
avatar_url=user_data.get('photo_200'),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,27 +1,15 @@
|
||||
"""Telegram authentication validation for cabinet."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qsl
|
||||
|
||||
import httpx
|
||||
import jwt as pyjwt
|
||||
import structlog
|
||||
from urllib.parse import parse_qsl, unquote
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# Maximum allowed clock skew (seconds) for auth_date — tolerates minor drift between Telegram servers and ours.
|
||||
_MAX_CLOCK_SKEW_SECONDS = 300
|
||||
|
||||
|
||||
def validate_telegram_login_widget(data: dict[str, Any], max_age_seconds: int = 86400) -> bool:
|
||||
"""
|
||||
Validate Telegram Login Widget data.
|
||||
@@ -41,27 +29,17 @@ def validate_telegram_login_widget(data: dict[str, Any], max_age_seconds: int =
|
||||
if not check_hash:
|
||||
return False
|
||||
|
||||
# Check auth_date is present and within valid range
|
||||
# Check auth_date is not too old
|
||||
auth_date = auth_data.get('auth_date')
|
||||
if not auth_date:
|
||||
return False
|
||||
try:
|
||||
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
|
||||
age = (datetime.now(UTC) - auth_time).total_seconds()
|
||||
if age > max_age_seconds or age < -_MAX_CLOCK_SKEW_SECONDS:
|
||||
logger.warning(
|
||||
'Telegram widget auth rejected: too old',
|
||||
age_hours=round(age / 3600, 1),
|
||||
max_age_hours=round(max_age_seconds / 3600, 1),
|
||||
)
|
||||
if auth_date:
|
||||
try:
|
||||
# Use UTC timestamp to avoid timezone issues
|
||||
auth_time = datetime.utcfromtimestamp(int(auth_date))
|
||||
age = (datetime.utcnow() - auth_time).total_seconds()
|
||||
if age > max_age_seconds:
|
||||
return False
|
||||
except (ValueError, TypeError, OSError):
|
||||
return False
|
||||
if age > 86400:
|
||||
logger.info(
|
||||
'Telegram widget auth accepted with stale auth_date',
|
||||
age_hours=round(age / 3600, 1),
|
||||
)
|
||||
except (ValueError, TypeError, OSError):
|
||||
return False
|
||||
|
||||
# Build data-check-string (sorted key=value pairs, newline-separated)
|
||||
data_check_arr = [f'{k}={v}' for k, v in sorted(auth_data.items()) if v is not None]
|
||||
@@ -98,27 +76,17 @@ def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) ->
|
||||
if not received_hash:
|
||||
return None
|
||||
|
||||
# Check auth_date is present and within valid range
|
||||
# Check auth_date is not too old
|
||||
auth_date = parsed.get('auth_date')
|
||||
if not auth_date:
|
||||
return None
|
||||
try:
|
||||
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
|
||||
age = (datetime.now(UTC) - auth_time).total_seconds()
|
||||
if age > max_age_seconds or age < -_MAX_CLOCK_SKEW_SECONDS:
|
||||
logger.warning(
|
||||
'Telegram initData rejected: too old',
|
||||
age_hours=round(age / 3600, 1),
|
||||
max_age_hours=round(max_age_seconds / 3600, 1),
|
||||
)
|
||||
if auth_date:
|
||||
try:
|
||||
# Use UTC timestamp to avoid timezone issues
|
||||
auth_time = datetime.utcfromtimestamp(int(auth_date))
|
||||
age = (datetime.utcnow() - auth_time).total_seconds()
|
||||
if age > max_age_seconds:
|
||||
return None
|
||||
except (ValueError, TypeError, OSError):
|
||||
return None
|
||||
if age > 86400:
|
||||
logger.info(
|
||||
'Telegram initData accepted with stale auth_date (Telegram caching bug)',
|
||||
age_hours=round(age / 3600, 1),
|
||||
)
|
||||
except (ValueError, TypeError, OSError):
|
||||
return None
|
||||
|
||||
# Build data-check-string
|
||||
data_check_arr = [f'{k}={v}' for k, v in sorted(parsed.items())]
|
||||
@@ -137,7 +105,7 @@ def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) ->
|
||||
# Parse user data from the validated data
|
||||
user_data_str = parsed.get('user')
|
||||
if user_data_str:
|
||||
user_data = json.loads(user_data_str)
|
||||
user_data = json.loads(unquote(user_data_str))
|
||||
return user_data
|
||||
|
||||
return parsed
|
||||
@@ -157,118 +125,3 @@ def extract_telegram_user_from_init_data(init_data: str) -> dict[str, Any] | Non
|
||||
User data dict with id, first_name, last_name, username, etc. or None if invalid
|
||||
"""
|
||||
return validate_telegram_init_data(init_data)
|
||||
|
||||
|
||||
# JWKS cache (module-level, refreshed periodically)
|
||||
_jwks_cache: dict[str, Any] = {}
|
||||
_jwks_cache_expiry: datetime | None = None
|
||||
_JWKS_CACHE_TTL_SECONDS = 3600 # 1 hour
|
||||
_JWKS_URL = 'https://oauth.telegram.org/.well-known/jwks.json'
|
||||
_OIDC_ISSUER = 'https://oauth.telegram.org'
|
||||
|
||||
_jwks_lock = asyncio.Lock()
|
||||
_jwks_last_force_refresh: datetime | None = None
|
||||
_JWKS_FORCE_REFRESH_COOLDOWN_SECONDS = 30
|
||||
|
||||
|
||||
def _build_public_keys(jwks_data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Build public key mapping from JWKS data."""
|
||||
public_keys: dict[str, Any] = {}
|
||||
for key_data in jwks_data.get('keys', []):
|
||||
kid = key_data.get('kid')
|
||||
if kid:
|
||||
public_keys[kid] = pyjwt.algorithms.RSAAlgorithm.from_jwk(key_data)
|
||||
return public_keys
|
||||
|
||||
|
||||
async def _get_jwks(force: bool = False) -> dict[str, Any]:
|
||||
"""Fetch and cache Telegram OIDC JWKS keys."""
|
||||
global _jwks_cache, _jwks_cache_expiry
|
||||
|
||||
now = datetime.now(UTC)
|
||||
if not force and _jwks_cache and _jwks_cache_expiry and now < _jwks_cache_expiry:
|
||||
return _jwks_cache
|
||||
|
||||
async with _jwks_lock:
|
||||
# Double-check after acquiring lock
|
||||
now = datetime.now(UTC)
|
||||
if not force and _jwks_cache and _jwks_cache_expiry and now < _jwks_cache_expiry:
|
||||
return _jwks_cache
|
||||
|
||||
proxy = settings.PROXY_URL if hasattr(settings, 'PROXY_URL') and settings.PROXY_URL else None
|
||||
async with httpx.AsyncClient(timeout=10, proxy=proxy) as client:
|
||||
response = await client.get(_JWKS_URL)
|
||||
response.raise_for_status()
|
||||
_jwks_cache = response.json()
|
||||
_jwks_cache_expiry = now + timedelta(seconds=_JWKS_CACHE_TTL_SECONDS)
|
||||
return _jwks_cache
|
||||
|
||||
|
||||
async def _force_refresh_jwks(kid: str) -> dict[str, Any] | None:
|
||||
"""Force JWKS refresh with cooldown protection. Returns refreshed JWKS or None if on cooldown."""
|
||||
global _jwks_cache_expiry, _jwks_last_force_refresh
|
||||
|
||||
async with _jwks_lock:
|
||||
now = datetime.now(UTC)
|
||||
if (
|
||||
_jwks_last_force_refresh
|
||||
and (now - _jwks_last_force_refresh).total_seconds() < _JWKS_FORCE_REFRESH_COOLDOWN_SECONDS
|
||||
):
|
||||
logger.warning('Telegram OIDC: JWKS force refresh on cooldown', kid=kid)
|
||||
return None
|
||||
_jwks_last_force_refresh = now
|
||||
_jwks_cache_expiry = None
|
||||
|
||||
return await _get_jwks(force=True)
|
||||
|
||||
|
||||
async def validate_telegram_oidc_token(id_token: str, client_id: str) -> dict[str, Any] | None:
|
||||
"""
|
||||
Validate a Telegram OIDC id_token using JWKS.
|
||||
|
||||
Args:
|
||||
id_token: JWT id_token from Telegram OIDC flow
|
||||
client_id: Expected audience (bot's numeric ID as string)
|
||||
|
||||
Returns:
|
||||
Decoded claims dict if valid, None otherwise.
|
||||
Claims include: sub, id, name, preferred_username, picture, iss, aud, exp, iat
|
||||
"""
|
||||
try:
|
||||
# Build public keys from JWKS
|
||||
jwks_data = await _get_jwks()
|
||||
public_keys = _build_public_keys(jwks_data)
|
||||
|
||||
# Decode header to get kid
|
||||
unverified_header = pyjwt.get_unverified_header(id_token)
|
||||
kid = unverified_header.get('kid')
|
||||
|
||||
# If kid not found, force JWKS refresh (key rotation) with cooldown
|
||||
if kid and kid not in public_keys:
|
||||
refreshed = await _force_refresh_jwks(kid)
|
||||
if refreshed:
|
||||
public_keys = _build_public_keys(refreshed)
|
||||
|
||||
if not kid or kid not in public_keys:
|
||||
logger.warning('Telegram OIDC: unknown kid in id_token', kid=kid)
|
||||
return None
|
||||
|
||||
claims = pyjwt.decode(
|
||||
id_token,
|
||||
key=public_keys[kid],
|
||||
algorithms=['RS256'],
|
||||
audience=client_id,
|
||||
issuer=_OIDC_ISSUER,
|
||||
options={'require': ['exp', 'iat', 'iss', 'aud', 'sub']},
|
||||
)
|
||||
return claims
|
||||
|
||||
except pyjwt.ExpiredSignatureError:
|
||||
logger.warning('Telegram OIDC: id_token expired')
|
||||
return None
|
||||
except pyjwt.InvalidTokenError as e:
|
||||
logger.warning('Telegram OIDC: invalid id_token', error=str(e))
|
||||
return None
|
||||
except httpx.HTTPError as e:
|
||||
logger.error('Telegram OIDC: failed to fetch JWKS', error=str(e))
|
||||
return None
|
||||
|
||||
+51
-199
@@ -1,9 +1,10 @@
|
||||
"""FastAPI dependencies for cabinet module."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import asyncio
|
||||
|
||||
import structlog
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from aiogram import Bot
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -15,14 +16,23 @@ from app.services.blacklist_service import blacklist_service
|
||||
from app.services.maintenance_service import maintenance_service
|
||||
|
||||
from .auth.jwt_handler import get_token_payload
|
||||
from .auth.telegram_auth import validate_telegram_init_data
|
||||
from .ip_utils import get_client_ip
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
# Кешированный Bot для проверки подписки на канал
|
||||
_channel_check_bot: Bot | None = None
|
||||
|
||||
|
||||
def _get_channel_check_bot() -> Bot:
|
||||
"""Получить или создать Bot для проверки подписки на канал."""
|
||||
global _channel_check_bot
|
||||
if _channel_check_bot is None:
|
||||
_channel_check_bot = Bot(token=settings.BOT_TOKEN)
|
||||
return _channel_check_bot
|
||||
|
||||
|
||||
async def get_cabinet_db() -> AsyncSession:
|
||||
"""Get database session for cabinet operations."""
|
||||
@@ -34,7 +44,6 @@ async def get_cabinet_db() -> AsyncSession:
|
||||
|
||||
|
||||
async def get_current_cabinet_user(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> User:
|
||||
@@ -42,7 +51,6 @@ async def get_current_cabinet_user(
|
||||
Get current authenticated cabinet user from JWT token.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object (for reading X-Telegram-Init-Data header)
|
||||
credentials: HTTP Bearer credentials
|
||||
db: Database session
|
||||
|
||||
@@ -97,34 +105,6 @@ async def get_current_cabinet_user(
|
||||
detail='User account is not active',
|
||||
)
|
||||
|
||||
# Defense in depth: cross-validate Telegram identity.
|
||||
# The frontend sends X-Telegram-Init-Data on every request.
|
||||
# If the header is present and cryptographically valid, verify that
|
||||
# the Telegram user ID matches the JWT user's telegram_id.
|
||||
# This prevents cross-account token reuse when Telegram WebView
|
||||
# shares localStorage across accounts on the same device.
|
||||
init_data_raw = request.headers.get('X-Telegram-Init-Data')
|
||||
if init_data_raw and user.telegram_id is not None:
|
||||
# Use generous max_age: Telegram Desktop caches initData
|
||||
tg_user = validate_telegram_init_data(init_data_raw, max_age_seconds=86400 * 30)
|
||||
if tg_user is None:
|
||||
logger.warning(
|
||||
'Telegram initData validation failed but header was present',
|
||||
jwt_user_id=user.id,
|
||||
)
|
||||
elif tg_user.get('id') != user.telegram_id:
|
||||
logger.warning(
|
||||
'Telegram identity mismatch: JWT belongs to different user than current Telegram account',
|
||||
jwt_user_id=user.id,
|
||||
jwt_telegram_id=user.telegram_id,
|
||||
init_data_telegram_id=tg_user.get('id'),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Session belongs to a different Telegram account. Please restart the app.',
|
||||
headers={'WWW-Authenticate': 'Bearer'},
|
||||
)
|
||||
|
||||
# Check blacklist
|
||||
if user.telegram_id is not None:
|
||||
is_blacklisted, reason = await blacklist_service.is_user_blacklisted(user.telegram_id, user.username)
|
||||
@@ -152,46 +132,47 @@ async def get_current_cabinet_user(
|
||||
},
|
||||
)
|
||||
|
||||
# Check required channel subscription - Telegram users only
|
||||
if settings.CHANNEL_IS_REQUIRED_SUB:
|
||||
# Skip for email-only users (no telegram_id)
|
||||
# Check required channel subscription - ТОЛЬКО для Telegram юзеров
|
||||
if settings.CHANNEL_IS_REQUIRED_SUB and settings.CHANNEL_SUB_ID:
|
||||
# Пропускаем проверку для email-only юзеров (нет telegram_id)
|
||||
if user.telegram_id is not None:
|
||||
# Skip admin check
|
||||
# Проверяем админа по telegram_id ИЛИ email
|
||||
is_admin = settings.is_admin(
|
||||
telegram_id=user.telegram_id, email=user.email if user.email_verified else None
|
||||
)
|
||||
if not is_admin:
|
||||
from app.services.channel_subscription_service import channel_subscription_service
|
||||
|
||||
channels_with_status = await channel_subscription_service.get_channels_with_status(user.telegram_id)
|
||||
is_subscribed = (
|
||||
all(ch['is_subscribed'] for ch in channels_with_status) if channels_with_status else True
|
||||
)
|
||||
|
||||
if not is_subscribed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={
|
||||
'code': 'channel_subscription_required',
|
||||
'message': 'Please subscribe to the required channels to continue',
|
||||
'channels': channels_with_status,
|
||||
},
|
||||
try:
|
||||
bot = _get_channel_check_bot()
|
||||
chat_member = await asyncio.wait_for(
|
||||
bot.get_chat_member(chat_id=settings.CHANNEL_SUB_ID, user_id=user.telegram_id),
|
||||
timeout=10.0,
|
||||
)
|
||||
# Не закрываем сессию - бот переиспользуется
|
||||
|
||||
# Throttled update of cabinet_last_login (at most every 5 minutes)
|
||||
now = datetime.now(UTC)
|
||||
if not user.cabinet_last_login or (now - user.cabinet_last_login).total_seconds() > 300:
|
||||
try:
|
||||
user.cabinet_last_login = now
|
||||
await db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
if chat_member.status not in ['member', 'administrator', 'creator']:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={
|
||||
'code': 'channel_subscription_required',
|
||||
'message': 'Please subscribe to our channel to continue',
|
||||
'channel_link': settings.CHANNEL_LINK,
|
||||
},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except TimeoutError:
|
||||
logger.warning('Timeout checking channel subscription for user', telegram_id=user.telegram_id)
|
||||
# Don't block user if check times out
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
'Failed to check channel subscription for user', telegram_id=user.telegram_id, error=e
|
||||
)
|
||||
# Don't block user if check fails
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_optional_cabinet_user(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> User | None:
|
||||
@@ -219,160 +200,31 @@ async def get_optional_cabinet_user(
|
||||
if not user or user.status != 'active':
|
||||
return None
|
||||
|
||||
# Cross-validate Telegram identity (same as get_current_cabinet_user)
|
||||
init_data_raw = request.headers.get('X-Telegram-Init-Data')
|
||||
if init_data_raw and user.telegram_id is not None:
|
||||
tg_user = validate_telegram_init_data(init_data_raw, max_age_seconds=86400 * 30)
|
||||
if tg_user and tg_user.get('id') != user.telegram_id:
|
||||
logger.warning(
|
||||
'Telegram identity mismatch in optional auth',
|
||||
jwt_user_id=user.id,
|
||||
jwt_telegram_id=user.telegram_id,
|
||||
init_data_telegram_id=tg_user.get('id'),
|
||||
)
|
||||
return None
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_admin_user(
|
||||
request: Request,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> User:
|
||||
"""
|
||||
Get current authenticated admin user.
|
||||
|
||||
Checks if the user is admin by legacy config (ADMIN_IDS / ADMIN_EMAILS)
|
||||
**or** by RBAC role assignment (any role with level > 0).
|
||||
Checks if the user is admin by telegram_id or email.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object
|
||||
user: Authenticated User object
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Authenticated admin User object
|
||||
|
||||
Raises:
|
||||
HTTPException: If user is not an admin by either mechanism
|
||||
HTTPException: If user is not an admin
|
||||
"""
|
||||
# Legacy check: config-based admin list
|
||||
is_legacy_admin = settings.is_admin(
|
||||
telegram_id=user.telegram_id,
|
||||
email=user.email if user.email_verified else None,
|
||||
)
|
||||
if is_legacy_admin:
|
||||
return user
|
||||
|
||||
# RBAC check: user has any active role with level > 0
|
||||
from app.database.crud.rbac import UserRoleCRUD
|
||||
|
||||
_permissions, _role_names, max_level = await UserRoleCRUD.get_user_permissions(db, user.id)
|
||||
if max_level > 0:
|
||||
return user
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Admin access required',
|
||||
)
|
||||
|
||||
|
||||
def require_permission(*permissions: str):
|
||||
"""
|
||||
FastAPI dependency factory for RBAC permission checks.
|
||||
|
||||
Usage::
|
||||
|
||||
@router.get("/users", dependencies=[Depends(require_permission("users:read"))])
|
||||
async def list_users(...): ...
|
||||
|
||||
# Or inject the user:
|
||||
@router.get("/users")
|
||||
async def list_users(user: User = Depends(require_permission("users:read"))): ...
|
||||
"""
|
||||
if not permissions:
|
||||
raise ValueError('require_permission() requires at least one permission argument')
|
||||
|
||||
async def dependency(
|
||||
request: Request,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> User:
|
||||
from app.services.permission_service import PermissionService
|
||||
|
||||
try:
|
||||
client_ip = get_client_ip(request)
|
||||
except HTTPException:
|
||||
logger.warning('Unable to determine client IP in require_permission')
|
||||
client_ip = 'unknown'
|
||||
user_agent = request.headers.get('user-agent', '')
|
||||
|
||||
# Extract resource_type from the first permission (section before ':')
|
||||
resource_type = None
|
||||
if permissions:
|
||||
first_perm = permissions[0]
|
||||
if ':' in first_perm:
|
||||
resource_type = first_perm.split(':', maxsplit=1)[0]
|
||||
|
||||
for perm in permissions:
|
||||
allowed, reason = await PermissionService.check_permission(
|
||||
db,
|
||||
user,
|
||||
perm,
|
||||
ip_address=client_ip,
|
||||
)
|
||||
if not allowed:
|
||||
await PermissionService.log_action(
|
||||
db,
|
||||
user_id=user.id,
|
||||
action=perm,
|
||||
resource_type=resource_type,
|
||||
status='denied',
|
||||
ip_address=client_ip,
|
||||
user_agent=user_agent,
|
||||
request_method=request.method,
|
||||
request_path=str(request.url.path),
|
||||
details={'reason': reason},
|
||||
)
|
||||
await db.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f'Permission denied: {reason}',
|
||||
)
|
||||
|
||||
# Capture request details
|
||||
details: dict = {
|
||||
'method': request.method,
|
||||
'path': str(request.url.path),
|
||||
}
|
||||
query_params = dict(request.query_params)
|
||||
if query_params:
|
||||
details['query_params'] = query_params
|
||||
if request.method in ('POST', 'PUT', 'PATCH', 'DELETE'):
|
||||
try:
|
||||
body = await request.body()
|
||||
if body:
|
||||
import json
|
||||
|
||||
details['request_body'] = json.loads(body)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Log successful access with all requested permissions
|
||||
await PermissionService.log_action(
|
||||
db,
|
||||
user_id=user.id,
|
||||
action=','.join(permissions),
|
||||
resource_type=resource_type,
|
||||
status='success',
|
||||
ip_address=client_ip,
|
||||
user_agent=user_agent,
|
||||
request_method=request.method,
|
||||
request_path=str(request.url.path),
|
||||
details=details,
|
||||
is_admin = settings.is_admin(telegram_id=user.telegram_id, email=user.email if user.email_verified else None)
|
||||
if not is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Admin access required',
|
||||
)
|
||||
await db.commit()
|
||||
return user
|
||||
|
||||
return dependency
|
||||
return user
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
"""Shared IP extraction utilities for cabinet module."""
|
||||
|
||||
from ipaddress import ip_address, ip_network
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def _is_trusted_proxy(peer_ip: str, trusted: set[str]) -> bool:
|
||||
"""Check if peer IP matches any trusted proxy entry (IP or CIDR)."""
|
||||
if not trusted:
|
||||
return False
|
||||
try:
|
||||
addr = ip_address(peer_ip)
|
||||
except ValueError:
|
||||
return False
|
||||
for entry in trusted:
|
||||
try:
|
||||
if '/' in entry:
|
||||
if addr in ip_network(entry, strict=False):
|
||||
return True
|
||||
elif addr == ip_address(entry):
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
"""Extract real client IP, trusting proxy headers only from known proxies.
|
||||
|
||||
Raises HTTPException 400 if the peer IP cannot be determined
|
||||
(request.client is None — e.g., test harness or broken transport).
|
||||
"""
|
||||
if not request.client:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Unable to determine client IP',
|
||||
)
|
||||
peer_ip = request.client.host
|
||||
trusted_proxies = settings.get_cabinet_trusted_proxies()
|
||||
|
||||
if trusted_proxies and _is_trusted_proxy(peer_ip, trusted_proxies):
|
||||
forwarded = request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
|
||||
if forwarded:
|
||||
try:
|
||||
ip_address(forwarded)
|
||||
return forwarded
|
||||
except ValueError:
|
||||
pass # invalid IP in header — fall through to peer_ip
|
||||
real_ip = request.headers.get('X-Real-IP', '').strip()
|
||||
if real_ip:
|
||||
try:
|
||||
ip_address(real_ip)
|
||||
return real_ip
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return peer_ip
|
||||
@@ -2,34 +2,18 @@
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .account_linking import merge_router as merge_router, router as account_linking_router
|
||||
from .admin_apps import router as admin_apps_router
|
||||
from .admin_audit_log import router as admin_audit_log_router
|
||||
from .admin_ban_system import router as admin_ban_system_router
|
||||
from .admin_broadcasts import router as admin_broadcasts_router
|
||||
from .admin_bulk_actions import router as admin_bulk_actions_router
|
||||
from .admin_button_styles import router as admin_button_styles_router
|
||||
from .admin_campaigns import router as admin_campaigns_router
|
||||
from .admin_channels import router as admin_channels_router
|
||||
from .admin_email_templates import router as admin_email_templates_router
|
||||
from .admin_info_pages import router as admin_info_pages_router
|
||||
from .admin_landings import router as admin_landings_router
|
||||
from .admin_menu_layout import router as admin_menu_layout_router
|
||||
from .admin_news import router as admin_news_router
|
||||
from .admin_news_categories import router as admin_news_categories_router
|
||||
from .admin_news_media import router as admin_news_media_router
|
||||
from .admin_news_tags import router as admin_news_tags_router
|
||||
from .admin_partners import router as admin_partners_router
|
||||
from .admin_payment_methods import router as admin_payment_methods_router
|
||||
from .admin_payments import router as admin_payments_router
|
||||
from .admin_pinned_messages import router as admin_pinned_messages_router
|
||||
from .admin_policies import router as admin_policies_router
|
||||
from .admin_promo_offers import router as admin_promo_offers_router
|
||||
from .admin_promocodes import promo_groups_router as admin_promo_groups_router, router as admin_promocodes_router
|
||||
from .admin_referral_network import router as admin_referral_network_router
|
||||
from .admin_remnawave import router as admin_remnawave_router
|
||||
from .admin_roles import router as admin_roles_router
|
||||
from .admin_sales_stats import router as admin_sales_stats_router
|
||||
from .admin_servers import router as admin_servers_router
|
||||
from .admin_settings import router as admin_settings_router
|
||||
from .admin_stats import router as admin_stats_router
|
||||
@@ -39,26 +23,19 @@ 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 .admin_withdrawals import router as admin_withdrawals_router
|
||||
from .auth import router as auth_router
|
||||
from .balance import router as balance_router
|
||||
from .branding import router as branding_router
|
||||
from .contests import router as contests_router
|
||||
from .gift import router as gift_router
|
||||
from .info import router as info_router
|
||||
from .info_pages import router as info_pages_router
|
||||
from .landing import router as landing_router
|
||||
from .media import router as media_router
|
||||
from .news import router as news_router
|
||||
from .notifications import router as notifications_router
|
||||
from .oauth import router as oauth_router
|
||||
from .partner_application import router as partner_application_router
|
||||
from .polls import router as polls_router
|
||||
from .promo import router as promo_router
|
||||
from .promocode import router as promocode_router
|
||||
from .referral import router as referral_router
|
||||
from .subscription import router as subscription_router
|
||||
from .subscription_modules.multi_tariff import router as multi_tariff_subscription_router
|
||||
from .ticket_notifications import (
|
||||
admin_router as admin_ticket_notifications_router,
|
||||
router as ticket_notifications_router,
|
||||
@@ -66,35 +43,17 @@ from .ticket_notifications import (
|
||||
from .tickets import router as tickets_router
|
||||
from .websocket import router as websocket_router
|
||||
from .wheel import router as wheel_router
|
||||
from .withdrawal import router as withdrawal_router
|
||||
|
||||
|
||||
# Conditional imports
|
||||
try:
|
||||
from .apple_iap import router as apple_iap_router
|
||||
except ImportError:
|
||||
apple_iap_router = None
|
||||
|
||||
|
||||
# Main cabinet router
|
||||
router = APIRouter(prefix='/cabinet', tags=['Cabinet'], redirect_slashes=False)
|
||||
router = APIRouter(prefix='/cabinet', tags=['Cabinet'])
|
||||
|
||||
# Include all sub-routers
|
||||
router.include_router(auth_router)
|
||||
router.include_router(oauth_router)
|
||||
router.include_router(account_linking_router)
|
||||
router.include_router(merge_router)
|
||||
router.include_router(subscription_router)
|
||||
router.include_router(multi_tariff_subscription_router)
|
||||
router.include_router(balance_router)
|
||||
router.include_router(referral_router)
|
||||
|
||||
# Apple IAP routes
|
||||
if apple_iap_router is not None:
|
||||
router.include_router(apple_iap_router)
|
||||
|
||||
router.include_router(partner_application_router)
|
||||
router.include_router(withdrawal_router)
|
||||
# Notifications router MUST be before tickets router to avoid route conflict
|
||||
router.include_router(ticket_notifications_router)
|
||||
router.include_router(tickets_router)
|
||||
@@ -105,38 +64,27 @@ router.include_router(promo_router)
|
||||
router.include_router(notifications_router)
|
||||
router.include_router(info_router)
|
||||
router.include_router(branding_router)
|
||||
router.include_router(landing_router)
|
||||
router.include_router(media_router)
|
||||
router.include_router(news_router)
|
||||
router.include_router(info_pages_router)
|
||||
|
||||
# Wheel routes
|
||||
router.include_router(wheel_router)
|
||||
|
||||
# Gift routes
|
||||
router.include_router(gift_router)
|
||||
|
||||
# Admin routes (notifications router MUST be before tickets router to avoid route conflict)
|
||||
router.include_router(admin_ticket_notifications_router)
|
||||
router.include_router(admin_tickets_router)
|
||||
router.include_router(admin_settings_router)
|
||||
router.include_router(admin_apps_router)
|
||||
router.include_router(admin_wheel_router)
|
||||
router.include_router(admin_tariffs_router)
|
||||
router.include_router(admin_servers_router)
|
||||
router.include_router(admin_stats_router)
|
||||
router.include_router(admin_referral_network_router)
|
||||
router.include_router(admin_sales_stats_router)
|
||||
router.include_router(admin_ban_system_router)
|
||||
router.include_router(admin_broadcasts_router)
|
||||
router.include_router(admin_promocodes_router)
|
||||
router.include_router(admin_promo_groups_router)
|
||||
router.include_router(admin_campaigns_router)
|
||||
router.include_router(admin_partners_router)
|
||||
router.include_router(admin_withdrawals_router)
|
||||
router.include_router(admin_users_router)
|
||||
router.include_router(admin_bulk_actions_router)
|
||||
router.include_router(admin_payment_methods_router)
|
||||
router.include_router(admin_landings_router)
|
||||
router.include_router(admin_payments_router)
|
||||
router.include_router(admin_promo_offers_router)
|
||||
router.include_router(admin_remnawave_router)
|
||||
@@ -145,19 +93,6 @@ router.include_router(admin_updates_router)
|
||||
router.include_router(admin_traffic_router)
|
||||
router.include_router(admin_pinned_messages_router)
|
||||
router.include_router(admin_button_styles_router)
|
||||
router.include_router(admin_menu_layout_router)
|
||||
router.include_router(admin_channels_router)
|
||||
router.include_router(admin_apps_router)
|
||||
router.include_router(admin_roles_router)
|
||||
router.include_router(admin_policies_router)
|
||||
router.include_router(admin_audit_log_router)
|
||||
# Categories/tags/media routers MUST be before the main news router
|
||||
# to avoid /admin/news/{article_id} catching /admin/news/categories etc.
|
||||
router.include_router(admin_news_categories_router)
|
||||
router.include_router(admin_news_tags_router)
|
||||
router.include_router(admin_news_media_router)
|
||||
router.include_router(admin_news_router)
|
||||
router.include_router(admin_info_pages_router)
|
||||
|
||||
# WebSocket route
|
||||
router.include_router(websocket_router)
|
||||
|
||||
@@ -1,930 +0,0 @@
|
||||
"""Account linking and merge routes for cabinet.
|
||||
|
||||
Router 1 (`router`): JWT-protected endpoints for linking/unlinking OAuth providers.
|
||||
Exception: `link/server-complete` uses state-token auth instead of JWT (for Mini App external browser flow).
|
||||
Router 2 (`merge_router`): Public endpoints for merge preview and execution.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal, NotRequired, TypedDict
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Request, status
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.system_setting import get_setting_value
|
||||
from app.database.crud.user import (
|
||||
OAUTH_PROVIDER_COLUMNS,
|
||||
clear_user_oauth_provider_id,
|
||||
get_user_by_id,
|
||||
get_user_by_oauth_provider,
|
||||
get_user_by_telegram_id,
|
||||
set_user_oauth_provider_id,
|
||||
)
|
||||
from app.database.models import User
|
||||
from app.services.account_merge_service import compute_auth_methods, execute_merge, get_merge_preview
|
||||
from app.utils.cache import RateLimitCache, TokenReplayCache
|
||||
|
||||
from ..auth.merge_service import (
|
||||
MERGE_TOKEN_TTL_SECONDS,
|
||||
consume_merge_token,
|
||||
create_merge_token,
|
||||
get_merge_token_data,
|
||||
restore_merge_token,
|
||||
)
|
||||
from ..auth.oauth_providers import (
|
||||
generate_oauth_state,
|
||||
get_provider,
|
||||
validate_oauth_state,
|
||||
)
|
||||
from ..auth.telegram_auth import (
|
||||
validate_telegram_init_data,
|
||||
validate_telegram_login_widget,
|
||||
validate_telegram_oidc_token,
|
||||
)
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..ip_utils import get_client_ip
|
||||
from ..schemas.auth import UserResponse
|
||||
from .auth import _create_auth_response, _store_refresh_token, _user_to_response
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
OAuthProviderName = Literal['google', 'yandex', 'discord', 'vk']
|
||||
|
||||
# Ensure OAuthProviderName Literal stays in sync with OAUTH_PROVIDER_COLUMNS
|
||||
_EXPECTED_PROVIDERS = {'google', 'yandex', 'discord', 'vk'}
|
||||
if set(OAUTH_PROVIDER_COLUMNS.keys()) != _EXPECTED_PROVIDERS:
|
||||
raise RuntimeError(
|
||||
f'OAuthProviderName Literal is out of sync with OAUTH_PROVIDER_COLUMNS: '
|
||||
f'{set(OAUTH_PROVIDER_COLUMNS.keys())} != {_EXPECTED_PROVIDERS}'
|
||||
)
|
||||
|
||||
|
||||
class OAuthStateData(TypedDict):
|
||||
"""Typed dict for Redis-stored OAuth state data."""
|
||||
|
||||
provider: str # Always present
|
||||
linking: NotRequired[str] # 'true' if account linking flow
|
||||
user_id: NotRequired[str] # ID of user who initiated linking
|
||||
code_verifier: NotRequired[str] # PKCE code verifier (VK)
|
||||
|
||||
|
||||
def _get_active_providers() -> list[str]:
|
||||
"""Вернуть список активных провайдеров аутентификации (только включённые)."""
|
||||
providers: list[str] = ['telegram']
|
||||
if settings.is_cabinet_email_auth_enabled():
|
||||
providers.append('email')
|
||||
providers.extend(settings.get_enabled_oauth_provider_names())
|
||||
return providers
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class LinkedProvider(BaseModel):
|
||||
provider: str
|
||||
linked: bool
|
||||
identifier: str | None = None
|
||||
|
||||
|
||||
class LinkedProvidersResponse(BaseModel):
|
||||
providers: list[LinkedProvider]
|
||||
|
||||
|
||||
class LinkInitResponse(BaseModel):
|
||||
authorize_url: str
|
||||
state: str
|
||||
|
||||
|
||||
class LinkCallbackRequest(BaseModel):
|
||||
code: str = Field(..., min_length=1, max_length=2048, description='Authorization code from provider')
|
||||
state: str = Field(..., min_length=1, max_length=128, description='CSRF state token')
|
||||
device_id: str | None = Field(None, max_length=256, description='Device ID from VK ID callback')
|
||||
|
||||
|
||||
class LinkCallbackResponse(BaseModel):
|
||||
success: bool
|
||||
message: str | None = None
|
||||
merge_required: bool = False
|
||||
merge_token: str | None = None
|
||||
|
||||
|
||||
class UnlinkResponse(BaseModel):
|
||||
success: bool
|
||||
|
||||
|
||||
class LinkTelegramRequest(BaseModel):
|
||||
"""Request for linking Telegram account. Supply EITHER init_data, id_token, OR widget fields."""
|
||||
|
||||
# Mini App: Telegram WebApp initData
|
||||
init_data: str | None = Field(None, max_length=4096, description='Telegram WebApp initData string')
|
||||
# OIDC: id_token from Telegram Login popup
|
||||
id_token: str | None = Field(None, max_length=4096, description='Telegram OIDC id_token (JWT)')
|
||||
# Login Widget fields
|
||||
id: int | None = Field(None, description='Telegram user ID from Login Widget')
|
||||
first_name: str | None = Field(None, max_length=256, description="User's first name")
|
||||
last_name: str | None = Field(None, max_length=256, description="User's last name")
|
||||
username: str | None = Field(None, max_length=256, description="User's username")
|
||||
photo_url: str | None = Field(None, max_length=2048, description="User's photo URL")
|
||||
auth_date: int | None = Field(None, description='Unix timestamp of authentication')
|
||||
hash: str | None = Field(None, min_length=64, max_length=64, description='Authentication hash (SHA-256 hex)')
|
||||
|
||||
@model_validator(mode='after')
|
||||
def check_exclusive(self) -> 'LinkTelegramRequest':
|
||||
has_init = self.init_data is not None
|
||||
has_oidc = self.id_token is not None
|
||||
has_widget = self.id is not None or self.hash is not None or self.auth_date is not None
|
||||
modes = sum([has_init, has_oidc, has_widget])
|
||||
if modes > 1:
|
||||
raise ValueError('Provide exactly one of: init_data, id_token, or Login Widget fields')
|
||||
if modes == 0:
|
||||
raise ValueError('Provide one of: init_data, id_token, or Login Widget fields (id, auth_date, hash)')
|
||||
if has_widget and not (self.id is not None and self.auth_date is not None and self.hash is not None):
|
||||
raise ValueError('Login Widget mode requires id, auth_date, and hash fields')
|
||||
return self
|
||||
|
||||
|
||||
class MergePreviewSubscription(BaseModel):
|
||||
status: str
|
||||
is_trial: bool
|
||||
end_date: datetime | None = None
|
||||
traffic_limit_gb: float
|
||||
traffic_used_gb: float
|
||||
device_limit: int
|
||||
tariff_name: str | None = None
|
||||
autopay_enabled: bool
|
||||
|
||||
|
||||
class MergePreviewUser(BaseModel):
|
||||
id: int
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
email: str | None = None
|
||||
auth_methods: list[str]
|
||||
balance_kopeks: int = 0
|
||||
subscription: MergePreviewSubscription | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class MergePreviewResponse(BaseModel):
|
||||
primary: MergePreviewUser
|
||||
secondary: MergePreviewUser
|
||||
expires_in_seconds: int
|
||||
|
||||
|
||||
class MergeRequest(BaseModel):
|
||||
keep_subscription_from: int = Field(..., description='User ID whose subscription to keep')
|
||||
|
||||
|
||||
class MergeResponse(BaseModel):
|
||||
success: bool
|
||||
access_token: str | None = None
|
||||
refresh_token: str | None = None
|
||||
user: UserResponse | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_provider_identifier(user: User, provider: str) -> str | None:
|
||||
"""Return the identifier (provider_id or email) for a given provider, or None."""
|
||||
match provider:
|
||||
case 'telegram':
|
||||
return str(user.telegram_id) if user.telegram_id else None
|
||||
case 'email':
|
||||
return user.email if user.email and user.password_hash else None
|
||||
case _:
|
||||
column = OAUTH_PROVIDER_COLUMNS.get(provider)
|
||||
if not column:
|
||||
return None
|
||||
value = getattr(user, column, None)
|
||||
return str(value) if value else None
|
||||
|
||||
|
||||
def _count_auth_methods(user: User) -> int:
|
||||
"""Count how many auth methods the user has linked."""
|
||||
return len(compute_auth_methods(user))
|
||||
|
||||
|
||||
async def _exchange_and_link_oauth(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
provider: str,
|
||||
code: str,
|
||||
state: str,
|
||||
state_data: OAuthStateData,
|
||||
device_id: str | None,
|
||||
log_context: str,
|
||||
) -> LinkCallbackResponse:
|
||||
"""Shared OAuth linking logic: exchange code, fetch user info, link or merge.
|
||||
|
||||
Used by both link_provider_callback (JWT-authed) and link_server_complete (state-authed).
|
||||
"""
|
||||
oauth_provider = get_provider(provider)
|
||||
if not oauth_provider:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Requested OAuth provider is not available',
|
||||
)
|
||||
|
||||
# Exchange code for tokens
|
||||
exchange_kwargs: dict[str, str] = {'state': state}
|
||||
code_verifier = state_data.get('code_verifier')
|
||||
if code_verifier:
|
||||
exchange_kwargs['code_verifier'] = code_verifier
|
||||
if device_id:
|
||||
exchange_kwargs['device_id'] = device_id
|
||||
|
||||
try:
|
||||
token_data = await oauth_provider.exchange_code(code, **exchange_kwargs)
|
||||
except Exception as exc:
|
||||
logger.error('OAuth code exchange failed', context=log_context, provider=provider, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to exchange authorization code',
|
||||
) from exc
|
||||
|
||||
# Fetch user info from provider
|
||||
try:
|
||||
user_info = await oauth_provider.get_user_info(token_data)
|
||||
except Exception as exc:
|
||||
logger.error('OAuth user info fetch failed', context=log_context, provider=provider, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to fetch user information from provider',
|
||||
) from exc
|
||||
|
||||
# Check if provider_id is already linked to THIS user
|
||||
column = OAUTH_PROVIDER_COLUMNS[provider]
|
||||
current_value = getattr(user, column, None)
|
||||
if current_value and str(current_value) == user_info.provider_id:
|
||||
return LinkCallbackResponse(success=True, message='already_linked')
|
||||
|
||||
# Check if provider_id is linked to ANOTHER user
|
||||
existing_user = await get_user_by_oauth_provider(db, provider, user_info.provider_id)
|
||||
if existing_user and existing_user.id != user.id:
|
||||
logger.info(
|
||||
'Account linking conflict: provider already linked to another user',
|
||||
context=log_context,
|
||||
provider=provider,
|
||||
provider_id=user_info.provider_id,
|
||||
current_user_id=user.id,
|
||||
existing_user_id=existing_user.id,
|
||||
)
|
||||
merge_token = await create_merge_token(
|
||||
primary_user_id=user.id,
|
||||
secondary_user_id=existing_user.id,
|
||||
provider=provider,
|
||||
provider_id=user_info.provider_id,
|
||||
)
|
||||
return LinkCallbackResponse(
|
||||
success=False,
|
||||
merge_required=True,
|
||||
merge_token=merge_token,
|
||||
)
|
||||
|
||||
# Link the provider to current user
|
||||
await set_user_oauth_provider_id(db, user, provider, user_info.provider_id)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='This provider account was just linked to another user',
|
||||
) from exc
|
||||
|
||||
logger.info(
|
||||
'OAuth provider linked to account',
|
||||
context=log_context,
|
||||
provider=provider,
|
||||
provider_id=user_info.provider_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
return LinkCallbackResponse(success=True, message='linked')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Router 1: Account linking (JWT required)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
router = APIRouter(prefix='/auth/account', tags=['Cabinet Account Linking'])
|
||||
|
||||
|
||||
@router.get('/linked-providers', response_model=LinkedProvidersResponse)
|
||||
async def get_linked_providers(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
) -> LinkedProvidersResponse:
|
||||
"""Return all auth methods with their link status for the current user."""
|
||||
providers: list[LinkedProvider] = []
|
||||
for provider in _get_active_providers():
|
||||
identifier = _get_provider_identifier(user, provider)
|
||||
providers.append(
|
||||
LinkedProvider(
|
||||
provider=provider,
|
||||
linked=identifier is not None,
|
||||
identifier=identifier,
|
||||
)
|
||||
)
|
||||
return LinkedProvidersResponse(providers=providers)
|
||||
|
||||
|
||||
@router.get('/link/{provider}/init', response_model=LinkInitResponse)
|
||||
async def link_provider_init(
|
||||
provider: OAuthProviderName,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
) -> LinkInitResponse:
|
||||
"""Start OAuth flow for linking a new provider to the current account."""
|
||||
|
||||
# Check if already linked
|
||||
column = OAUTH_PROVIDER_COLUMNS[provider]
|
||||
if getattr(user, column, None):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Provider is already linked to your account',
|
||||
)
|
||||
|
||||
oauth_provider = get_provider(provider)
|
||||
if not oauth_provider:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Requested OAuth provider is not available',
|
||||
)
|
||||
|
||||
# Generate PKCE data for VK (and potentially future providers)
|
||||
auth_extra = oauth_provider.prepare_auth_state()
|
||||
extra_data: dict[str, str] = {
|
||||
'linking': 'true',
|
||||
'user_id': str(user.id),
|
||||
}
|
||||
if auth_extra:
|
||||
extra_data.update(auth_extra)
|
||||
|
||||
state = await generate_oauth_state(provider, extra_data=extra_data)
|
||||
# Only pass URL-safe params (prefixed with _) to authorize URL; exclude secrets like code_verifier
|
||||
url_params = {k: v for k, v in auth_extra.items() if k.startswith('_')} if auth_extra else {}
|
||||
authorize_url = oauth_provider.get_authorization_url(state, **url_params)
|
||||
|
||||
return LinkInitResponse(authorize_url=authorize_url, state=state)
|
||||
|
||||
|
||||
@router.post('/link/{provider}/callback', response_model=LinkCallbackResponse)
|
||||
async def link_provider_callback(
|
||||
provider: OAuthProviderName,
|
||||
request: LinkCallbackRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> LinkCallbackResponse:
|
||||
"""Handle OAuth callback for linking a provider to the current account."""
|
||||
# 1. Validate CSRF state
|
||||
state_data = await validate_oauth_state(request.state, provider)
|
||||
if not state_data:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid or expired OAuth state',
|
||||
)
|
||||
|
||||
# 1b. Validate that this state was created for account linking (not login)
|
||||
if state_data.get('linking') != 'true' or not state_data.get('user_id'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='OAuth state was not initiated for account linking',
|
||||
)
|
||||
|
||||
# 1c. Validate that the user who initiated the link flow is the same user completing it
|
||||
state_user_id = state_data['user_id']
|
||||
if str(user.id) != state_user_id:
|
||||
logger.warning(
|
||||
'OAuth state user_id mismatch in link callback',
|
||||
state_user_id=state_user_id,
|
||||
current_user_id=user.id,
|
||||
provider=provider,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='OAuth state was initiated by a different user',
|
||||
)
|
||||
|
||||
# 2-7. Exchange code, fetch user info, link or merge
|
||||
return await _exchange_and_link_oauth(
|
||||
db=db,
|
||||
user=user,
|
||||
provider=provider,
|
||||
code=request.code,
|
||||
state=request.state,
|
||||
state_data=state_data,
|
||||
device_id=request.device_id,
|
||||
log_context='link-callback',
|
||||
)
|
||||
|
||||
|
||||
@router.post('/unlink/{provider}', response_model=UnlinkResponse)
|
||||
async def unlink_provider(
|
||||
provider: OAuthProviderName,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> UnlinkResponse:
|
||||
"""Unlink an OAuth provider from the current account."""
|
||||
column = OAUTH_PROVIDER_COLUMNS[provider]
|
||||
if not getattr(user, column, None):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Provider is not linked to your account',
|
||||
)
|
||||
|
||||
# Ensure at least one auth method remains
|
||||
if _count_auth_methods(user) <= 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Cannot unlink last authentication method',
|
||||
)
|
||||
|
||||
await clear_user_oauth_provider_id(db, user, provider)
|
||||
await db.commit()
|
||||
return UnlinkResponse(success=True)
|
||||
|
||||
|
||||
@router.post('/link/telegram', response_model=LinkCallbackResponse)
|
||||
async def link_telegram(
|
||||
request: LinkTelegramRequest,
|
||||
raw_request: Request,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> LinkCallbackResponse:
|
||||
"""Link Telegram account via WebApp initData, OIDC id_token, or Login Widget."""
|
||||
# Rate limit
|
||||
client_ip = get_client_ip(raw_request)
|
||||
if await RateLimitCache.is_ip_rate_limited(client_ip, 'link_telegram', limit=10, window=60, fail_closed=True):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail='Too many requests',
|
||||
headers={'Retry-After': '60'},
|
||||
)
|
||||
|
||||
# 1. Already has Telegram linked?
|
||||
if user.telegram_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Telegram is already linked to your account',
|
||||
)
|
||||
|
||||
# 2. Validate and extract telegram_id
|
||||
telegram_id: int | None = None
|
||||
telegram_username: str | None = None
|
||||
telegram_first_name: str | None = None
|
||||
telegram_last_name: str | None = None
|
||||
|
||||
if request.init_data:
|
||||
# Mini App flow: validate initData
|
||||
# Generous max_age: Telegram Desktop/iOS cache initData with stale auth_date
|
||||
user_data = validate_telegram_init_data(request.init_data, max_age_seconds=86400 * 30)
|
||||
if not user_data or not user_data.get('id'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid or expired Telegram initData',
|
||||
)
|
||||
telegram_id = int(user_data['id'])
|
||||
telegram_username = user_data.get('username')
|
||||
telegram_first_name = user_data.get('first_name')
|
||||
telegram_last_name = user_data.get('last_name')
|
||||
elif request.id_token:
|
||||
# OIDC flow: validate id_token via JWKS
|
||||
oidc_enabled_val = await get_setting_value(db, 'TELEGRAM_OIDC_ENABLED')
|
||||
oidc_client_id_val = await get_setting_value(db, 'TELEGRAM_OIDC_CLIENT_ID')
|
||||
oidc_client_id = oidc_client_id_val or settings.TELEGRAM_OIDC_CLIENT_ID
|
||||
oidc_enabled = (
|
||||
oidc_enabled_val.lower() == 'true' if oidc_enabled_val is not None else settings.TELEGRAM_OIDC_ENABLED
|
||||
) and bool(oidc_client_id)
|
||||
|
||||
if not oidc_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Telegram OIDC is not configured',
|
||||
)
|
||||
|
||||
claims = await validate_telegram_oidc_token(request.id_token, oidc_client_id)
|
||||
if not claims:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Invalid or expired Telegram OIDC token',
|
||||
)
|
||||
|
||||
# Replay detection
|
||||
token_hash = hashlib.sha256(request.id_token.encode()).hexdigest()
|
||||
token_ttl = max(int(claims.get('exp', 0) - datetime.now(UTC).timestamp()), 60)
|
||||
if await TokenReplayCache.is_token_replayed(token_hash, ttl=min(token_ttl, 600)):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Invalid or expired Telegram OIDC token',
|
||||
)
|
||||
|
||||
try:
|
||||
telegram_id = int(claims.get('id', claims.get('sub', 0)))
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Invalid user ID in OIDC claims',
|
||||
) from exc
|
||||
if not telegram_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Missing user ID in OIDC claims',
|
||||
)
|
||||
|
||||
telegram_username = claims.get('preferred_username')
|
||||
telegram_first_name = claims.get('name', claims.get('given_name', ''))
|
||||
telegram_last_name = claims.get('family_name')
|
||||
elif request.id is not None and request.hash is not None and request.auth_date is not None:
|
||||
# Login Widget flow: validate widget hash
|
||||
widget_data = {
|
||||
'id': request.id,
|
||||
'auth_date': request.auth_date,
|
||||
'hash': request.hash,
|
||||
}
|
||||
if request.first_name is not None:
|
||||
widget_data['first_name'] = request.first_name
|
||||
if request.last_name is not None:
|
||||
widget_data['last_name'] = request.last_name
|
||||
if request.username is not None:
|
||||
widget_data['username'] = request.username
|
||||
if request.photo_url is not None:
|
||||
widget_data['photo_url'] = request.photo_url
|
||||
|
||||
# Generous max_age: Telegram caches auth data with stale auth_date
|
||||
if not validate_telegram_login_widget(widget_data, max_age_seconds=86400 * 30):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid or expired Telegram Login Widget data',
|
||||
)
|
||||
telegram_id = request.id
|
||||
telegram_username = request.username
|
||||
telegram_first_name = request.first_name
|
||||
telegram_last_name = request.last_name
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Provide init_data (Mini App), id_token (OIDC), or Login Widget fields (id, auth_date, hash)',
|
||||
)
|
||||
|
||||
# 3. Check if telegram_id is linked to ANOTHER user
|
||||
existing_user = await get_user_by_telegram_id(db, telegram_id)
|
||||
if existing_user and existing_user.id != user.id:
|
||||
logger.info(
|
||||
'Telegram linking conflict: telegram_id already linked to another user',
|
||||
telegram_id=telegram_id,
|
||||
current_user_id=user.id,
|
||||
existing_user_id=existing_user.id,
|
||||
)
|
||||
merge_token = await create_merge_token(
|
||||
primary_user_id=user.id,
|
||||
secondary_user_id=existing_user.id,
|
||||
provider='telegram',
|
||||
provider_id=str(telegram_id),
|
||||
)
|
||||
return LinkCallbackResponse(
|
||||
success=False,
|
||||
merge_required=True,
|
||||
merge_token=merge_token,
|
||||
)
|
||||
|
||||
# 4. Link Telegram to current user
|
||||
user.telegram_id = telegram_id
|
||||
if telegram_username and not user.username:
|
||||
user.username = telegram_username
|
||||
if telegram_first_name and not user.first_name:
|
||||
user.first_name = telegram_first_name
|
||||
if telegram_last_name and not user.last_name:
|
||||
user.last_name = telegram_last_name
|
||||
user.updated_at = datetime.now(UTC)
|
||||
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='This Telegram account was just linked to another user',
|
||||
) from exc
|
||||
|
||||
logger.info(
|
||||
'Telegram linked to account',
|
||||
telegram_id=telegram_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
# BUG-1 fix: Sync all subscriptions with RemnaWave panel so it knows the new telegram_id
|
||||
try:
|
||||
from app.services.remnawave_resync_service import resync_user_subscriptions_with_panel
|
||||
|
||||
resync_result = await resync_user_subscriptions_with_panel(db, user)
|
||||
logger.info(
|
||||
'Post-TG-link resync completed',
|
||||
user_id=user.id,
|
||||
telegram_id=telegram_id,
|
||||
synced=resync_result['synced'],
|
||||
failed=resync_result['failed'],
|
||||
)
|
||||
except Exception as resync_error:
|
||||
logger.error(
|
||||
'Post-TG-link resync failed (non-fatal)',
|
||||
user_id=user.id,
|
||||
error=resync_error,
|
||||
)
|
||||
return LinkCallbackResponse(success=True, message='linked')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Server-side OAuth linking callback (NO JWT required — auth via state token)
|
||||
# Used by Telegram Mini App where OAuth must open in external browser.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ServerCompleteRequest(BaseModel):
|
||||
code: str = Field(..., min_length=1, max_length=2048, description='Authorization code from provider')
|
||||
state: str = Field(..., min_length=1, max_length=128, description='CSRF state token')
|
||||
provider: OAuthProviderName | None = Field(None, description='OAuth provider name (resolved from state if omitted)')
|
||||
device_id: str | None = Field(None, max_length=256, description='Device ID from VK ID callback')
|
||||
|
||||
|
||||
class ServerCompleteResponse(LinkCallbackResponse):
|
||||
provider: str
|
||||
|
||||
|
||||
@router.post('/link/server-complete', response_model=ServerCompleteResponse)
|
||||
async def link_server_complete(
|
||||
request: ServerCompleteRequest,
|
||||
raw_request: Request,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> ServerCompleteResponse:
|
||||
"""Complete OAuth account linking without JWT.
|
||||
|
||||
Authenticates via the one-time state token stored in Redis during link_provider_init.
|
||||
Used when OAuth opens in an external browser (e.g., from Telegram Mini App).
|
||||
Provider is resolved from the state token if not explicitly provided.
|
||||
"""
|
||||
# Rate limit by IP (unauthenticated endpoint)
|
||||
client_ip = get_client_ip(raw_request)
|
||||
if await RateLimitCache.is_ip_rate_limited(client_ip, 'server_complete', limit=10, window=60, fail_closed=True):
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
|
||||
|
||||
# 1. Validate and consume state from Redis (one-time use).
|
||||
# Provider may be None — validate_oauth_state will skip provider check,
|
||||
# and we'll resolve it from state_data['provider'].
|
||||
state_data = await validate_oauth_state(request.state, request.provider)
|
||||
if not state_data:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid or expired OAuth state',
|
||||
)
|
||||
|
||||
# Resolve provider from state data (canonical source)
|
||||
state_provider: str = state_data.get('provider', '')
|
||||
if not state_provider or state_provider not in OAUTH_PROVIDER_COLUMNS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Could not determine OAuth provider',
|
||||
)
|
||||
|
||||
# If request explicitly provides a provider, ensure it matches the state
|
||||
if request.provider and request.provider != state_provider:
|
||||
logger.warning(
|
||||
'Provider mismatch in server-complete',
|
||||
request_provider=request.provider,
|
||||
state_provider=state_provider,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Provider does not match OAuth state',
|
||||
)
|
||||
|
||||
provider_name: str = state_provider
|
||||
|
||||
# 2. Must be a linking state (not login)
|
||||
if state_data.get('linking') != 'true' or not state_data.get('user_id'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='OAuth state was not initiated for account linking',
|
||||
)
|
||||
|
||||
# 3. Parse and validate user_id from state
|
||||
try:
|
||||
user_id = int(state_data['user_id'])
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid user_id in OAuth state',
|
||||
) from exc
|
||||
|
||||
# 4. Load user from DB
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='User not found',
|
||||
)
|
||||
|
||||
# 5-9. Exchange code, fetch user info, link or merge
|
||||
result = await _exchange_and_link_oauth(
|
||||
db=db,
|
||||
user=user,
|
||||
provider=provider_name,
|
||||
code=request.code,
|
||||
state=request.state,
|
||||
state_data=state_data,
|
||||
device_id=request.device_id,
|
||||
log_context='server-complete',
|
||||
)
|
||||
|
||||
return ServerCompleteResponse(
|
||||
success=result.success,
|
||||
message=result.message,
|
||||
merge_required=result.merge_required,
|
||||
merge_token=result.merge_token,
|
||||
provider=provider_name,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Router 2: Merge (NO JWT required)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
merge_router = APIRouter(prefix='/auth/merge', tags=['Cabinet Account Merge'])
|
||||
|
||||
|
||||
@merge_router.get('/{merge_token}', response_model=MergePreviewResponse)
|
||||
async def get_merge_preview_endpoint(
|
||||
raw_request: Request,
|
||||
merge_token: str = Path(..., min_length=32, max_length=64),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> MergePreviewResponse:
|
||||
"""Preview the result of merging two accounts before confirming."""
|
||||
# Rate limit by IP (unauthenticated endpoint)
|
||||
client_ip = get_client_ip(raw_request)
|
||||
if await RateLimitCache.is_ip_rate_limited(client_ip, 'merge_preview', limit=15, window=60, fail_closed=True):
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
|
||||
|
||||
token_data = await get_merge_token_data(merge_token)
|
||||
if not token_data:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Merge token is invalid or expired',
|
||||
)
|
||||
|
||||
primary_user_id: int = token_data['primary_user_id']
|
||||
secondary_user_id: int = token_data['secondary_user_id']
|
||||
|
||||
try:
|
||||
preview = await get_merge_preview(db, primary_user_id, secondary_user_id)
|
||||
except ValueError as exc:
|
||||
logger.error('Merge preview failed', error=str(exc))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='One or both users not found',
|
||||
) from exc
|
||||
|
||||
# Calculate remaining TTL
|
||||
created_at_str: str = token_data.get('created_at', '')
|
||||
try:
|
||||
created_at = datetime.fromisoformat(created_at_str)
|
||||
if created_at.tzinfo is None:
|
||||
created_at = created_at.replace(tzinfo=UTC)
|
||||
elapsed = (datetime.now(UTC) - created_at).total_seconds()
|
||||
expires_in_seconds = max(0, int(MERGE_TOKEN_TTL_SECONDS - elapsed))
|
||||
except (ValueError, TypeError):
|
||||
expires_in_seconds = 0
|
||||
|
||||
return MergePreviewResponse(
|
||||
primary=MergePreviewUser(**preview['primary']),
|
||||
secondary=MergePreviewUser(**preview['secondary']),
|
||||
expires_in_seconds=expires_in_seconds,
|
||||
)
|
||||
|
||||
|
||||
@merge_router.post('/{merge_token}', response_model=MergeResponse)
|
||||
async def execute_merge_endpoint(
|
||||
request: MergeRequest,
|
||||
raw_request: Request,
|
||||
merge_token: str = Path(..., min_length=32, max_length=64),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> MergeResponse:
|
||||
"""Execute account merge. Consumes the merge token (one-time use)."""
|
||||
# Rate limit by IP (unauthenticated endpoint)
|
||||
client_ip = get_client_ip(raw_request)
|
||||
if await RateLimitCache.is_ip_rate_limited(client_ip, 'merge_execute', limit=5, window=60, fail_closed=True):
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
|
||||
|
||||
# 1. Consume token atomically first (GETDEL — one-time use, no TOCTOU)
|
||||
consumed = await consume_merge_token(merge_token)
|
||||
if not consumed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Merge token is invalid, expired, or already consumed',
|
||||
)
|
||||
|
||||
primary_user_id: int = consumed['primary_user_id']
|
||||
secondary_user_id: int = consumed['secondary_user_id']
|
||||
provider: str = consumed.get('provider', '')
|
||||
provider_id: str = consumed.get('provider_id', '')
|
||||
|
||||
# 2. Validate keep_subscription_from — restore token if invalid
|
||||
if request.keep_subscription_from not in (primary_user_id, secondary_user_id):
|
||||
await restore_merge_token(merge_token, consumed)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='keep_subscription_from must be one of the two user IDs being merged',
|
||||
)
|
||||
|
||||
# Convert user_id to 'primary'/'secondary' string for execute_merge()
|
||||
keep_from: Literal['primary', 'secondary'] = (
|
||||
'primary' if request.keep_subscription_from == primary_user_id else 'secondary'
|
||||
)
|
||||
|
||||
# 3. Execute merge
|
||||
try:
|
||||
merged_user = await execute_merge(
|
||||
db=db,
|
||||
primary_user_id=primary_user_id,
|
||||
secondary_user_id=secondary_user_id,
|
||||
keep_subscription_from=keep_from,
|
||||
provider=provider,
|
||||
provider_id=provider_id,
|
||||
)
|
||||
await db.commit()
|
||||
except ValueError as exc:
|
||||
await db.rollback()
|
||||
await restore_merge_token(merge_token, consumed)
|
||||
logger.error('Merge execution failed (ValueError)', error=str(exc))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Account merge cannot be completed. The accounts may have already been merged or deleted.',
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
await restore_merge_token(merge_token, consumed)
|
||||
logger.exception('Merge execution failed')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Account merge failed due to an internal error',
|
||||
) from exc
|
||||
|
||||
# 4. Re-fetch merged user with full relationships for auth response
|
||||
merged_user = await get_user_by_id(db, primary_user_id)
|
||||
if not merged_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load merged user',
|
||||
)
|
||||
|
||||
# BUG-7 fix: Resync merged user's subscriptions with RemnaWave panel
|
||||
try:
|
||||
from app.services.remnawave_resync_service import resync_user_subscriptions_with_panel
|
||||
|
||||
resync_result = await resync_user_subscriptions_with_panel(db, merged_user)
|
||||
logger.info(
|
||||
'Post-merge resync completed',
|
||||
primary_user_id=primary_user_id,
|
||||
secondary_user_id=secondary_user_id,
|
||||
synced=resync_result['synced'],
|
||||
failed=resync_result['failed'],
|
||||
)
|
||||
except Exception as resync_error:
|
||||
logger.error(
|
||||
'Post-merge resync failed (non-fatal)',
|
||||
primary_user_id=primary_user_id,
|
||||
error=resync_error,
|
||||
)
|
||||
|
||||
# 5. Create auth tokens for the merged user
|
||||
try:
|
||||
auth_response = await _create_auth_response(merged_user, db)
|
||||
await _store_refresh_token(db, merged_user.id, auth_response.refresh_token, device_info='merge')
|
||||
except Exception as exc:
|
||||
logger.exception('Failed to create auth tokens after merge')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Merge succeeded but failed to create new session',
|
||||
) from exc
|
||||
|
||||
logger.info(
|
||||
'Account merge completed successfully',
|
||||
primary_user_id=primary_user_id,
|
||||
secondary_user_id=secondary_user_id,
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
return MergeResponse(
|
||||
success=True,
|
||||
access_token=auth_response.access_token,
|
||||
refresh_token=auth_response.refresh_token,
|
||||
user=_user_to_response(merged_user),
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Admin routes for managing RemnaWave app configuration."""
|
||||
"""Admin routes for managing VPN applications in app-config.json."""
|
||||
|
||||
import re
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
@@ -12,7 +13,7 @@ from app.database.models import User
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.system_settings_service import bot_configuration_service
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -23,6 +24,431 @@ router = APIRouter(prefix='/admin/apps', tags=['Cabinet Admin Apps'])
|
||||
# ============ Schemas ============
|
||||
|
||||
|
||||
class LocalizedText(BaseModel):
|
||||
"""Localized text for multiple languages."""
|
||||
|
||||
en: str = ''
|
||||
ru: str = ''
|
||||
zh: str | None = ''
|
||||
fa: str | None = ''
|
||||
|
||||
|
||||
class AppButton(BaseModel):
|
||||
"""Button with link and localized text."""
|
||||
|
||||
buttonLink: str
|
||||
buttonText: LocalizedText
|
||||
|
||||
|
||||
class AppStep(BaseModel):
|
||||
"""Step with description and optional buttons/title."""
|
||||
|
||||
description: LocalizedText
|
||||
buttons: list[AppButton] | None = None
|
||||
title: LocalizedText | None = None
|
||||
|
||||
|
||||
class AppDefinition(BaseModel):
|
||||
"""VPN application definition."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
isFeatured: bool = False
|
||||
urlScheme: str
|
||||
isNeedBase64Encoding: bool | None = None
|
||||
installationStep: AppStep
|
||||
addSubscriptionStep: AppStep
|
||||
connectAndUseStep: AppStep
|
||||
additionalBeforeAddSubscriptionStep: AppStep | None = None
|
||||
additionalAfterAddSubscriptionStep: AppStep | None = None
|
||||
|
||||
|
||||
class PlatformApps(BaseModel):
|
||||
"""Apps for a specific platform."""
|
||||
|
||||
platform: str
|
||||
apps: list[AppDefinition]
|
||||
|
||||
|
||||
class AppConfigBranding(BaseModel):
|
||||
"""Branding configuration."""
|
||||
|
||||
name: str
|
||||
logoUrl: str
|
||||
supportUrl: str
|
||||
|
||||
|
||||
class AppConfigConfig(BaseModel):
|
||||
"""Top-level config section."""
|
||||
|
||||
additionalLocales: list[str]
|
||||
branding: AppConfigBranding
|
||||
|
||||
|
||||
class AppConfigResponse(BaseModel):
|
||||
"""Full app config response."""
|
||||
|
||||
config: AppConfigConfig
|
||||
platforms: dict[str, list[AppDefinition]]
|
||||
|
||||
|
||||
class CreateAppRequest(BaseModel):
|
||||
"""Request to create a new app."""
|
||||
|
||||
platform: str
|
||||
app: AppDefinition
|
||||
|
||||
|
||||
class UpdateAppRequest(BaseModel):
|
||||
"""Request to update an app."""
|
||||
|
||||
app: AppDefinition
|
||||
|
||||
|
||||
class ReorderAppsRequest(BaseModel):
|
||||
"""Request to reorder apps in a platform."""
|
||||
|
||||
app_ids: list[str]
|
||||
|
||||
|
||||
class UpdateBrandingRequest(BaseModel):
|
||||
"""Request to update branding."""
|
||||
|
||||
branding: AppConfigBranding
|
||||
|
||||
|
||||
# ============ Helpers ============
|
||||
|
||||
|
||||
def _get_config_path() -> Path:
|
||||
"""Get path to app-config.json."""
|
||||
return Path(settings.get_app_config_path())
|
||||
|
||||
|
||||
def _load_config() -> dict:
|
||||
"""Load app config from file."""
|
||||
config_path = _get_config_path()
|
||||
if not config_path.exists():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f'App config file not found: {config_path}',
|
||||
)
|
||||
|
||||
try:
|
||||
with open(config_path, encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f'Failed to parse app config: {e}',
|
||||
)
|
||||
|
||||
|
||||
def _save_config(config: dict) -> None:
|
||||
"""Save app config to file."""
|
||||
config_path = _get_config_path()
|
||||
|
||||
try:
|
||||
with open(config_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(config, f, indent=2, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f'Failed to save app config: {e}',
|
||||
)
|
||||
|
||||
|
||||
VALID_PLATFORMS = ['ios', 'android', 'macos', 'windows', 'linux', 'androidTV', 'appleTV']
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
|
||||
@router.get('', response_model=AppConfigResponse)
|
||||
async def get_app_config(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get full app configuration."""
|
||||
config = _load_config()
|
||||
return config
|
||||
|
||||
|
||||
@router.get('/platforms', response_model=list[str])
|
||||
async def get_platforms(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get list of available platforms."""
|
||||
return VALID_PLATFORMS
|
||||
|
||||
|
||||
@router.get('/platforms/{platform}', response_model=list[AppDefinition])
|
||||
async def get_platform_apps(
|
||||
platform: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get apps for a specific platform."""
|
||||
if platform not in VALID_PLATFORMS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid platform: {platform}. Valid platforms: {VALID_PLATFORMS}',
|
||||
)
|
||||
|
||||
config = _load_config()
|
||||
platforms = config.get('platforms', {})
|
||||
return platforms.get(platform, [])
|
||||
|
||||
|
||||
@router.post('/platforms/{platform}', response_model=AppDefinition)
|
||||
async def create_app(
|
||||
platform: str,
|
||||
request: CreateAppRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Create a new app for a platform."""
|
||||
if platform not in VALID_PLATFORMS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid platform: {platform}',
|
||||
)
|
||||
|
||||
config = _load_config()
|
||||
platforms = config.get('platforms', {})
|
||||
|
||||
if platform not in platforms:
|
||||
platforms[platform] = []
|
||||
|
||||
# Check if app with same ID already exists
|
||||
existing_ids = [app.get('id') for app in platforms[platform]]
|
||||
if request.app.id in existing_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"App with ID '{request.app.id}' already exists in {platform}",
|
||||
)
|
||||
|
||||
# Add new app
|
||||
app_dict = request.app.model_dump(exclude_none=True)
|
||||
platforms[platform].append(app_dict)
|
||||
config['platforms'] = platforms
|
||||
|
||||
_save_config(config)
|
||||
logger.info('Admin created app for platform', admin_id=admin.id, app_id=request.app.id, platform=platform)
|
||||
|
||||
return request.app
|
||||
|
||||
|
||||
@router.put('/platforms/{platform}/{app_id}', response_model=AppDefinition)
|
||||
async def update_app(
|
||||
platform: str,
|
||||
app_id: str,
|
||||
request: UpdateAppRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Update an existing app."""
|
||||
if platform not in VALID_PLATFORMS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid platform: {platform}',
|
||||
)
|
||||
|
||||
config = _load_config()
|
||||
platforms = config.get('platforms', {})
|
||||
apps = platforms.get(platform, [])
|
||||
|
||||
# Find and update app
|
||||
app_index = None
|
||||
for i, app in enumerate(apps):
|
||||
if app.get('id') == app_id:
|
||||
app_index = i
|
||||
break
|
||||
|
||||
if app_index is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"App '{app_id}' not found in platform '{platform}'",
|
||||
)
|
||||
|
||||
# Update app
|
||||
app_dict = request.app.model_dump(exclude_none=True)
|
||||
apps[app_index] = app_dict
|
||||
platforms[platform] = apps
|
||||
config['platforms'] = platforms
|
||||
|
||||
_save_config(config)
|
||||
logger.info('Admin updated app in platform', admin_id=admin.id, app_id=app_id, platform=platform)
|
||||
|
||||
return request.app
|
||||
|
||||
|
||||
@router.delete('/platforms/{platform}/{app_id}')
|
||||
async def delete_app(
|
||||
platform: str,
|
||||
app_id: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Delete an app from a platform."""
|
||||
if platform not in VALID_PLATFORMS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid platform: {platform}',
|
||||
)
|
||||
|
||||
config = _load_config()
|
||||
platforms = config.get('platforms', {})
|
||||
apps = platforms.get(platform, [])
|
||||
|
||||
# Find and remove app
|
||||
original_length = len(apps)
|
||||
apps = [app for app in apps if app.get('id') != app_id]
|
||||
|
||||
if len(apps) == original_length:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"App '{app_id}' not found in platform '{platform}'",
|
||||
)
|
||||
|
||||
platforms[platform] = apps
|
||||
config['platforms'] = platforms
|
||||
|
||||
_save_config(config)
|
||||
logger.info('Admin deleted app from platform', admin_id=admin.id, app_id=app_id, platform=platform)
|
||||
|
||||
return {'status': 'deleted', 'app_id': app_id}
|
||||
|
||||
|
||||
@router.post('/platforms/{platform}/reorder')
|
||||
async def reorder_apps(
|
||||
platform: str,
|
||||
request: ReorderAppsRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Reorder apps in a platform."""
|
||||
if platform not in VALID_PLATFORMS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid platform: {platform}',
|
||||
)
|
||||
|
||||
config = _load_config()
|
||||
platforms = config.get('platforms', {})
|
||||
apps = platforms.get(platform, [])
|
||||
|
||||
# Create a map of apps by ID
|
||||
apps_map = {app.get('id'): app for app in apps}
|
||||
|
||||
# Verify all IDs exist
|
||||
for app_id in request.app_ids:
|
||||
if app_id not in apps_map:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"App '{app_id}' not found in platform '{platform}'",
|
||||
)
|
||||
|
||||
# Reorder apps
|
||||
reordered_apps = [apps_map[app_id] for app_id in request.app_ids]
|
||||
|
||||
# Add any apps that weren't in the reorder list (shouldn't happen but just in case)
|
||||
for app in apps:
|
||||
if app.get('id') not in request.app_ids:
|
||||
reordered_apps.append(app)
|
||||
|
||||
platforms[platform] = reordered_apps
|
||||
config['platforms'] = platforms
|
||||
|
||||
_save_config(config)
|
||||
logger.info('Admin reordered apps in platform', admin_id=admin.id, platform=platform)
|
||||
|
||||
return {'status': 'reordered', 'order': request.app_ids}
|
||||
|
||||
|
||||
@router.put('/branding', response_model=AppConfigBranding)
|
||||
async def update_branding(
|
||||
request: UpdateBrandingRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Update branding configuration."""
|
||||
config = _load_config()
|
||||
|
||||
if 'config' not in config:
|
||||
config['config'] = {}
|
||||
|
||||
config['config']['branding'] = request.branding.model_dump()
|
||||
|
||||
_save_config(config)
|
||||
logger.info('Admin updated branding', admin_id=admin.id)
|
||||
|
||||
return request.branding
|
||||
|
||||
|
||||
@router.get('/branding', response_model=AppConfigBranding)
|
||||
async def get_branding(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get branding configuration."""
|
||||
config = _load_config()
|
||||
branding = config.get('config', {}).get('branding', {})
|
||||
return branding
|
||||
|
||||
|
||||
@router.post('/platforms/{platform}/copy/{app_id}')
|
||||
async def copy_app_to_platform(
|
||||
platform: str,
|
||||
app_id: str,
|
||||
target_platform: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Copy an app from one platform to another."""
|
||||
if platform not in VALID_PLATFORMS or target_platform not in VALID_PLATFORMS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid platform(s)',
|
||||
)
|
||||
|
||||
config = _load_config()
|
||||
platforms = config.get('platforms', {})
|
||||
source_apps = platforms.get(platform, [])
|
||||
|
||||
# Find source app
|
||||
source_app = None
|
||||
for app in source_apps:
|
||||
if app.get('id') == app_id:
|
||||
source_app = app.copy()
|
||||
break
|
||||
|
||||
if not source_app:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"App '{app_id}' not found in platform '{platform}'",
|
||||
)
|
||||
|
||||
# Generate new ID for copied app
|
||||
import time
|
||||
|
||||
new_id = f'{app_id}-copy-{int(time.time())}'
|
||||
source_app['id'] = new_id
|
||||
|
||||
# Add to target platform
|
||||
if target_platform not in platforms:
|
||||
platforms[target_platform] = []
|
||||
|
||||
platforms[target_platform].append(source_app)
|
||||
config['platforms'] = platforms
|
||||
|
||||
_save_config(config)
|
||||
logger.info(
|
||||
'Admin copied app from to as',
|
||||
admin_id=admin.id,
|
||||
app_id=app_id,
|
||||
platform=platform,
|
||||
target_platform=target_platform,
|
||||
new_id=new_id,
|
||||
)
|
||||
|
||||
return {'status': 'copied', 'new_id': new_id, 'target_platform': target_platform}
|
||||
|
||||
|
||||
# ============ RemnaWave Config Routes ============
|
||||
|
||||
|
||||
class RemnaWaveConfigStatus(BaseModel):
|
||||
"""Status of RemnaWave config integration."""
|
||||
|
||||
@@ -36,11 +462,6 @@ class UpdateRemnaWaveUuidRequest(BaseModel):
|
||||
uuid: str | None = None
|
||||
|
||||
|
||||
# ============ Helpers ============
|
||||
|
||||
_UUID_PATTERN = re.compile(r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')
|
||||
|
||||
|
||||
def _get_remnawave_config_uuid() -> str | None:
|
||||
"""Get RemnaWave config UUID from system settings or env."""
|
||||
try:
|
||||
@@ -49,12 +470,9 @@ def _get_remnawave_config_uuid() -> str | None:
|
||||
return settings.CABINET_REMNA_SUB_CONFIG
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
|
||||
@router.get('/remnawave/status', response_model=RemnaWaveConfigStatus)
|
||||
async def get_remnawave_config_status(
|
||||
admin: User = Depends(require_permission('apps:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get RemnaWave config integration status."""
|
||||
config_uuid = _get_remnawave_config_uuid()
|
||||
@@ -67,26 +485,27 @@ async def get_remnawave_config_status(
|
||||
@router.put('/remnawave/uuid', response_model=RemnaWaveConfigStatus)
|
||||
async def set_remnawave_config_uuid(
|
||||
request: UpdateRemnaWaveUuidRequest,
|
||||
admin: User = Depends(require_permission('apps:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Set RemnaWave subscription config UUID."""
|
||||
uuid_value = request.uuid.strip() if request.uuid else None
|
||||
|
||||
if uuid_value and not _UUID_PATTERN.match(uuid_value):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid UUID format',
|
||||
)
|
||||
# Validate UUID format if provided
|
||||
if uuid_value:
|
||||
import re
|
||||
|
||||
uuid_pattern = re.compile(r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')
|
||||
if not uuid_pattern.match(uuid_value):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid UUID format',
|
||||
)
|
||||
|
||||
try:
|
||||
await bot_configuration_service.set_value(db, 'CABINET_REMNA_SUB_CONFIG', uuid_value)
|
||||
await db.commit()
|
||||
|
||||
from app.handlers.subscription.common import invalidate_app_config_cache
|
||||
|
||||
invalidate_app_config_cache()
|
||||
logger.info('Admin updated CABINET_REMNA_SUB_CONFIG', admin_id=admin.id, uuid_value=uuid_value)
|
||||
logger.info('Admin updated CABINET_REMNA_SUB_CONFIG to', admin_id=admin.id, uuid_value=uuid_value)
|
||||
except Exception as e:
|
||||
logger.error('Error saving RemnaWave config UUID', error=e)
|
||||
raise HTTPException(
|
||||
@@ -102,14 +521,17 @@ async def set_remnawave_config_uuid(
|
||||
|
||||
@router.get('/remnawave/config')
|
||||
async def get_remnawave_subscription_config(
|
||||
admin: User = Depends(require_permission('apps:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Fetch subscription page config from RemnaWave panel."""
|
||||
"""
|
||||
Fetch subscription page config from RemnaWave panel.
|
||||
Uses CABINET_REMNA_SUB_CONFIG setting for the config UUID.
|
||||
"""
|
||||
config_uuid = _get_remnawave_config_uuid()
|
||||
if not config_uuid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='RemnaWave subscription config is not configured',
|
||||
detail='CABINET_REMNA_SUB_CONFIG is not configured',
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -119,9 +541,10 @@ async def get_remnawave_subscription_config(
|
||||
if not config:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Subscription config not found',
|
||||
detail=f"Subscription config '{config_uuid}' not found in RemnaWave",
|
||||
)
|
||||
|
||||
# Return the raw config data from RemnaWave
|
||||
return {
|
||||
'uuid': config.uuid,
|
||||
'name': config.name,
|
||||
@@ -134,13 +557,13 @@ async def get_remnawave_subscription_config(
|
||||
logger.error('Error fetching RemnaWave config', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to fetch config from RemnaWave',
|
||||
detail=f'Failed to fetch config from RemnaWave: {e!s}',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/remnawave/configs')
|
||||
async def list_remnawave_subscription_configs(
|
||||
admin: User = Depends(require_permission('apps:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""List available subscription page configs from RemnaWave panel."""
|
||||
try:
|
||||
@@ -159,5 +582,5 @@ async def list_remnawave_subscription_configs(
|
||||
logger.error('Error listing RemnaWave configs', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to fetch configs from RemnaWave',
|
||||
detail=f'Failed to fetch configs from RemnaWave: {e!s}',
|
||||
)
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
"""Admin audit log routes — view and export admin action history."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.rbac import AuditLogCRUD
|
||||
from app.database.models import User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/rbac/audit-log', tags=['Admin RBAC Audit Log'])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
|
||||
class AuditLogEntry(BaseModel):
|
||||
"""Single audit log entry."""
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
action: str
|
||||
resource_type: str | None = None
|
||||
resource_id: str | None = None
|
||||
details: dict[str, Any] | None = None
|
||||
ip_address: str | None = None
|
||||
user_agent: str | None = None
|
||||
status: str
|
||||
request_method: str | None = None
|
||||
request_path: str | None = None
|
||||
created_at: datetime | None = None
|
||||
user_first_name: str | None = None
|
||||
user_email: str | None = None
|
||||
|
||||
|
||||
class AuditLogListResponse(BaseModel):
|
||||
"""Paginated audit log list."""
|
||||
|
||||
items: list[AuditLogEntry]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
# ============ CSV Export ============
|
||||
|
||||
_CSV_COLUMNS = [
|
||||
'id',
|
||||
'user_id',
|
||||
'action',
|
||||
'resource_type',
|
||||
'resource_id',
|
||||
'status',
|
||||
'ip_address',
|
||||
'request_method',
|
||||
'request_path',
|
||||
'created_at',
|
||||
'user_agent',
|
||||
'details',
|
||||
]
|
||||
|
||||
|
||||
def _sanitize_csv_cell(value: str) -> str:
|
||||
"""Prevent CSV formula injection by prefixing dangerous leading characters."""
|
||||
if value and value[0] in ('=', '+', '-', '@', '\t', '\r'):
|
||||
return f"'{value}"
|
||||
return value
|
||||
|
||||
|
||||
def _logs_to_csv(logs) -> str:
|
||||
"""Serialize audit log entries to CSV string."""
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(_CSV_COLUMNS)
|
||||
|
||||
for log in logs:
|
||||
writer.writerow(
|
||||
[
|
||||
log.id,
|
||||
log.user_id,
|
||||
log.action,
|
||||
log.resource_type or '',
|
||||
log.resource_id or '',
|
||||
log.status,
|
||||
log.ip_address or '',
|
||||
log.request_method or '',
|
||||
_sanitize_csv_cell(log.request_path or ''),
|
||||
log.created_at.isoformat() if log.created_at else '',
|
||||
_sanitize_csv_cell((log.user_agent or '')[:200]),
|
||||
_sanitize_csv_cell(str(log.details) if log.details else ''),
|
||||
]
|
||||
)
|
||||
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
|
||||
@router.get('', response_model=AuditLogListResponse)
|
||||
async def list_audit_logs(
|
||||
admin: User = Depends(require_permission('audit_log:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
user_id: int | None = Query(default=None),
|
||||
action: str | None = Query(default=None),
|
||||
resource_type: str | None = Query(default=None),
|
||||
status: str | None = Query(default=None),
|
||||
date_from: datetime | None = Query(default=None),
|
||||
date_to: datetime | None = Query(default=None),
|
||||
limit: int = Query(default=50, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
):
|
||||
"""List audit log entries with optional filters and pagination."""
|
||||
logs, total = await AuditLogCRUD.get_logs(
|
||||
db,
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
status=status,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
load_user=True,
|
||||
)
|
||||
|
||||
items = [
|
||||
AuditLogEntry(
|
||||
id=log.id,
|
||||
user_id=log.user_id,
|
||||
action=log.action,
|
||||
resource_type=log.resource_type,
|
||||
resource_id=log.resource_id,
|
||||
details=log.details,
|
||||
ip_address=log.ip_address,
|
||||
user_agent=log.user_agent,
|
||||
status=log.status,
|
||||
request_method=log.request_method,
|
||||
request_path=log.request_path,
|
||||
created_at=log.created_at,
|
||||
user_first_name=log.user.first_name if log.user else None,
|
||||
user_email=log.user.email if log.user else None,
|
||||
)
|
||||
for log in logs
|
||||
]
|
||||
|
||||
return AuditLogListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/export')
|
||||
async def export_audit_logs(
|
||||
admin: User = Depends(require_permission('audit_log:export')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
user_id: int | None = Query(default=None),
|
||||
action: str | None = Query(default=None),
|
||||
resource_type: str | None = Query(default=None),
|
||||
status: str | None = Query(default=None),
|
||||
date_from: datetime | None = Query(default=None),
|
||||
date_to: datetime | None = Query(default=None),
|
||||
limit: int = Query(default=10000, ge=1, le=50000),
|
||||
):
|
||||
"""Export audit logs as CSV file."""
|
||||
logs, _total = await AuditLogCRUD.get_logs(
|
||||
db,
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
status=status,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
limit=limit,
|
||||
offset=0,
|
||||
)
|
||||
|
||||
csv_content = _logs_to_csv(logs)
|
||||
timestamp = datetime.now(UTC).strftime('%Y%m%d_%H%M%S')
|
||||
filename = f'audit_log_{timestamp}.csv'
|
||||
|
||||
logger.info(
|
||||
'Admin exported audit logs',
|
||||
admin_id=admin.id,
|
||||
rows=len(logs),
|
||||
filename=filename,
|
||||
)
|
||||
|
||||
return StreamingResponse(
|
||||
iter([csv_content]),
|
||||
media_type='text/csv',
|
||||
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
|
||||
)
|
||||
@@ -9,7 +9,7 @@ from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.external.ban_system_api import BanSystemAPI, BanSystemAPIError
|
||||
|
||||
from ..dependencies import require_permission
|
||||
from ..dependencies import get_current_admin_user
|
||||
from ..schemas.ban_system import (
|
||||
BanAgentHistoryItem,
|
||||
BanAgentHistoryResponse,
|
||||
@@ -103,7 +103,7 @@ async def _api_request(api: BanSystemAPI, method: str, *args, **kwargs) -> Any:
|
||||
|
||||
@router.get('/status', response_model=BanSystemStatusResponse)
|
||||
async def get_ban_system_status(
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> BanSystemStatusResponse:
|
||||
"""Get Ban System integration status."""
|
||||
return BanSystemStatusResponse(
|
||||
@@ -117,7 +117,7 @@ async def get_ban_system_status(
|
||||
|
||||
@router.get('/stats/raw')
|
||||
async def get_stats_raw(
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> dict:
|
||||
"""Get raw stats from Ban System API for debugging."""
|
||||
api = _get_ban_api()
|
||||
@@ -127,7 +127,7 @@ async def get_stats_raw(
|
||||
|
||||
@router.get('/stats', response_model=BanSystemStatsResponse)
|
||||
async def get_stats(
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> BanSystemStatsResponse:
|
||||
"""Get overall Ban System statistics."""
|
||||
from datetime import datetime
|
||||
@@ -181,7 +181,7 @@ async def get_users(
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
status: str | None = Query(None, description='Filter: over_limit, with_limit, unlimited'),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> BanUsersListResponse:
|
||||
"""Get list of users from Ban System."""
|
||||
api = _get_ban_api()
|
||||
@@ -211,7 +211,7 @@ async def get_users(
|
||||
@router.get('/users/over-limit', response_model=BanUsersListResponse)
|
||||
async def get_users_over_limit(
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> BanUsersListResponse:
|
||||
"""Get users who exceeded their device limit."""
|
||||
api = _get_ban_api()
|
||||
@@ -241,7 +241,7 @@ async def get_users_over_limit(
|
||||
@router.get('/users/search/{query}')
|
||||
async def search_users(
|
||||
query: str,
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> BanUsersListResponse:
|
||||
"""Search for users."""
|
||||
api = _get_ban_api()
|
||||
@@ -272,7 +272,7 @@ async def search_users(
|
||||
@router.get('/users/{email}', response_model=BanUserDetailResponse)
|
||||
async def get_user_detail(
|
||||
email: str,
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> BanUserDetailResponse:
|
||||
"""Get detailed user information."""
|
||||
api = _get_ban_api()
|
||||
@@ -325,7 +325,7 @@ async def get_user_detail(
|
||||
|
||||
@router.get('/punishments', response_model=BanPunishmentsListResponse)
|
||||
async def get_punishments(
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> BanPunishmentsListResponse:
|
||||
"""Get list of active punishments (bans)."""
|
||||
api = _get_ban_api()
|
||||
@@ -360,7 +360,7 @@ async def get_punishments(
|
||||
@router.post('/punishments/{user_id}/unban', response_model=UnbanResponse)
|
||||
async def unban_user(
|
||||
user_id: str,
|
||||
admin: User = Depends(require_permission('ban_system:unban')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> UnbanResponse:
|
||||
"""Unban (enable) a user."""
|
||||
api = _get_ban_api()
|
||||
@@ -377,7 +377,7 @@ async def unban_user(
|
||||
@router.post('/ban', response_model=UnbanResponse)
|
||||
async def ban_user(
|
||||
request: BanUserRequest,
|
||||
admin: User = Depends(require_permission('ban_system:ban')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> UnbanResponse:
|
||||
"""Manually ban a user."""
|
||||
api = _get_ban_api()
|
||||
@@ -401,7 +401,7 @@ async def ban_user(
|
||||
async def get_punishment_history(
|
||||
query: str,
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> BanHistoryResponse:
|
||||
"""Get punishment history for a user."""
|
||||
api = _get_ban_api()
|
||||
@@ -438,7 +438,7 @@ async def get_punishment_history(
|
||||
|
||||
@router.get('/nodes', response_model=BanNodesListResponse)
|
||||
async def get_nodes(
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> BanNodesListResponse:
|
||||
"""Get list of connected nodes."""
|
||||
api = _get_ban_api()
|
||||
@@ -480,7 +480,7 @@ async def get_agents(
|
||||
search: str | None = Query(None),
|
||||
health: str | None = Query(None, description='healthy, warning, critical'),
|
||||
agent_status: str | None = Query(None, alias='status', description='online, offline'),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> BanAgentsListResponse:
|
||||
"""Get list of monitoring agents."""
|
||||
api = _get_ban_api()
|
||||
@@ -579,7 +579,7 @@ async def get_agents(
|
||||
|
||||
@router.get('/agents/summary', response_model=BanAgentsSummary)
|
||||
async def get_agents_summary(
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> BanAgentsSummary:
|
||||
"""Get agents summary statistics."""
|
||||
api = _get_ban_api()
|
||||
@@ -603,7 +603,7 @@ async def get_agents_summary(
|
||||
@router.get('/traffic/violations', response_model=BanTrafficViolationsResponse)
|
||||
async def get_traffic_violations(
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> BanTrafficViolationsResponse:
|
||||
"""Get list of traffic limit violations."""
|
||||
api = _get_ban_api()
|
||||
@@ -637,7 +637,7 @@ async def get_traffic_violations(
|
||||
|
||||
@router.get('/traffic', response_model=BanTrafficResponse)
|
||||
async def get_traffic(
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> BanTrafficResponse:
|
||||
"""Get full traffic statistics including top users."""
|
||||
api = _get_ban_api()
|
||||
@@ -681,7 +681,7 @@ async def get_traffic(
|
||||
@router.get('/traffic/top')
|
||||
async def get_traffic_top(
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> list[BanTrafficTopItem]:
|
||||
"""Get top users by traffic."""
|
||||
api = _get_ban_api()
|
||||
@@ -744,7 +744,7 @@ def _parse_setting_response(key: str, data: Any, default_type: str = 'str') -> B
|
||||
|
||||
@router.get('/settings', response_model=BanSettingsResponse)
|
||||
async def get_settings(
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> BanSettingsResponse:
|
||||
"""Get all Ban System settings."""
|
||||
api = _get_ban_api()
|
||||
@@ -802,7 +802,7 @@ async def get_settings(
|
||||
@router.get('/settings/{key}')
|
||||
async def get_setting(
|
||||
key: str,
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> BanSettingDefinition:
|
||||
"""Get a specific setting."""
|
||||
api = _get_ban_api()
|
||||
@@ -815,7 +815,7 @@ async def get_setting(
|
||||
async def set_setting(
|
||||
key: str,
|
||||
value: str = Query(...),
|
||||
admin: User = Depends(require_permission('ban_system:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> BanSettingDefinition:
|
||||
"""Set a setting value."""
|
||||
api = _get_ban_api()
|
||||
@@ -829,7 +829,7 @@ async def set_setting(
|
||||
@router.post('/settings/{key}/toggle')
|
||||
async def toggle_setting(
|
||||
key: str,
|
||||
admin: User = Depends(require_permission('ban_system:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> BanSettingDefinition:
|
||||
"""Toggle a boolean setting."""
|
||||
api = _get_ban_api()
|
||||
@@ -846,7 +846,7 @@ async def toggle_setting(
|
||||
@router.post('/settings/whitelist/add', response_model=UnbanResponse)
|
||||
async def whitelist_add(
|
||||
request: BanWhitelistRequest,
|
||||
admin: User = Depends(require_permission('ban_system:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> UnbanResponse:
|
||||
"""Add user to whitelist."""
|
||||
api = _get_ban_api()
|
||||
@@ -863,7 +863,7 @@ async def whitelist_add(
|
||||
@router.post('/settings/whitelist/remove', response_model=UnbanResponse)
|
||||
async def whitelist_remove(
|
||||
request: BanWhitelistRequest,
|
||||
admin: User = Depends(require_permission('ban_system:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> UnbanResponse:
|
||||
"""Remove user from whitelist."""
|
||||
api = _get_ban_api()
|
||||
@@ -883,7 +883,7 @@ async def whitelist_remove(
|
||||
@router.get('/report', response_model=BanReportResponse)
|
||||
async def get_report(
|
||||
hours: int = Query(24, ge=1, le=168),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> BanReportResponse:
|
||||
"""Get period report."""
|
||||
api = _get_ban_api()
|
||||
@@ -913,7 +913,7 @@ async def get_report(
|
||||
|
||||
@router.get('/health', response_model=BanHealthResponse)
|
||||
async def get_health(
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> BanHealthResponse:
|
||||
"""Get Ban System health status."""
|
||||
api = _get_ban_api()
|
||||
@@ -947,7 +947,7 @@ async def get_health(
|
||||
|
||||
@router.get('/health/detailed', response_model=BanHealthDetailedResponse)
|
||||
async def get_health_detailed(
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> BanHealthDetailedResponse:
|
||||
"""Get detailed health information."""
|
||||
api = _get_ban_api()
|
||||
@@ -967,7 +967,7 @@ async def get_health_detailed(
|
||||
async def get_agent_history(
|
||||
node_name: str,
|
||||
hours: int = Query(24, ge=1, le=168),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> BanAgentHistoryResponse:
|
||||
"""Get agent statistics history."""
|
||||
api = _get_ban_api()
|
||||
@@ -1003,7 +1003,7 @@ async def get_agent_history(
|
||||
async def get_user_punishment_history(
|
||||
email: str,
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> BanHistoryResponse:
|
||||
"""Get punishment history for a specific user."""
|
||||
api = _get_ban_api()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Admin routes for broadcasts in cabinet."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
@@ -18,7 +18,7 @@ from app.services.broadcast_service import (
|
||||
email_broadcast_service,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.broadcasts import (
|
||||
BroadcastButton,
|
||||
BroadcastButtonsResponse,
|
||||
@@ -118,10 +118,9 @@ EMAIL_FILTER_GROUPS = {
|
||||
|
||||
def _serialize_broadcast(broadcast: BroadcastHistory) -> BroadcastResponse:
|
||||
"""Serialize broadcast to response model."""
|
||||
blocked = broadcast.blocked_count or 0
|
||||
progress = 0.0
|
||||
if broadcast.total_count > 0:
|
||||
progress = round((broadcast.sent_count + broadcast.failed_count + blocked) / broadcast.total_count * 100, 1)
|
||||
progress = round((broadcast.sent_count + broadcast.failed_count) / broadcast.total_count * 100, 1)
|
||||
|
||||
return BroadcastResponse(
|
||||
id=broadcast.id,
|
||||
@@ -134,14 +133,12 @@ def _serialize_broadcast(broadcast: BroadcastHistory) -> BroadcastResponse:
|
||||
total_count=broadcast.total_count,
|
||||
sent_count=broadcast.sent_count,
|
||||
failed_count=broadcast.failed_count,
|
||||
blocked_count=blocked,
|
||||
status=broadcast.status,
|
||||
admin_id=broadcast.admin_id,
|
||||
admin_name=broadcast.admin_name,
|
||||
created_at=broadcast.created_at,
|
||||
completed_at=broadcast.completed_at,
|
||||
progress_percent=progress,
|
||||
category=getattr(broadcast, 'category', 'system') or 'system',
|
||||
channel=getattr(broadcast, 'channel', 'telegram') or 'telegram',
|
||||
email_subject=getattr(broadcast, 'email_subject', None),
|
||||
email_html_content=getattr(broadcast, 'email_html_content', None),
|
||||
@@ -248,7 +245,7 @@ def _validate_buttons(buttons: list[str]) -> bool:
|
||||
|
||||
@router.get('/filters', response_model=BroadcastFiltersResponse)
|
||||
async def get_filters(
|
||||
admin: User = Depends(require_permission('broadcasts:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> BroadcastFiltersResponse:
|
||||
"""Get all available filters with user counts."""
|
||||
@@ -311,7 +308,7 @@ async def get_filters(
|
||||
|
||||
@router.get('/tariffs', response_model=BroadcastTariffsResponse)
|
||||
async def get_tariffs(
|
||||
admin: User = Depends(require_permission('broadcasts:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> BroadcastTariffsResponse:
|
||||
"""Get tariffs for broadcast filtering."""
|
||||
@@ -334,7 +331,7 @@ async def get_tariffs(
|
||||
|
||||
@router.get('/buttons', response_model=BroadcastButtonsResponse)
|
||||
async def get_buttons(
|
||||
admin: User = Depends(require_permission('broadcasts:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> BroadcastButtonsResponse:
|
||||
"""Get available buttons for broadcasts."""
|
||||
default_buttons = set(DEFAULT_BROADCAST_BUTTONS)
|
||||
@@ -353,7 +350,7 @@ async def get_buttons(
|
||||
@router.post('/preview', response_model=BroadcastPreviewResponse)
|
||||
async def preview_broadcast(
|
||||
request: BroadcastPreviewRequest,
|
||||
admin: User = Depends(require_permission('broadcasts:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> BroadcastPreviewResponse:
|
||||
"""Preview broadcast recipients count."""
|
||||
@@ -382,7 +379,7 @@ async def preview_broadcast(
|
||||
@router.post('', response_model=BroadcastResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_broadcast(
|
||||
request: BroadcastCreateRequest,
|
||||
admin: User = Depends(require_permission('broadcasts:create')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> BroadcastResponse:
|
||||
"""Create and start a broadcast."""
|
||||
@@ -412,13 +409,6 @@ async def create_broadcast(
|
||||
|
||||
media_payload = request.media
|
||||
|
||||
# Validate caption length for media messages (Telegram limit: 1024 chars)
|
||||
if media_payload and len(message_text) > 1024:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Текст слишком длинный для сообщения с медиа. Максимум 1024 символов, сейчас {len(message_text)}. Сократите текст или уберите медиафайл.',
|
||||
)
|
||||
|
||||
# Create broadcast record
|
||||
broadcast = BroadcastHistory(
|
||||
target_type=request.target,
|
||||
@@ -433,7 +423,6 @@ async def create_broadcast(
|
||||
status='queued',
|
||||
admin_id=admin.id,
|
||||
admin_name=admin.username or f'Admin #{admin.id}',
|
||||
category=request.category,
|
||||
)
|
||||
db.add(broadcast)
|
||||
await db.commit()
|
||||
@@ -455,8 +444,6 @@ async def create_broadcast(
|
||||
selected_buttons=request.selected_buttons,
|
||||
media=media_config,
|
||||
initiator_name=admin.username or f'Admin #{admin.id}',
|
||||
custom_buttons=[btn.model_dump() for btn in request.custom_buttons] if request.custom_buttons else None,
|
||||
category=request.category,
|
||||
)
|
||||
|
||||
# Start broadcast
|
||||
@@ -472,7 +459,7 @@ async def create_broadcast(
|
||||
|
||||
@router.get('', response_model=BroadcastListResponse)
|
||||
async def list_broadcasts(
|
||||
admin: User = Depends(require_permission('broadcasts:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
@@ -498,7 +485,7 @@ async def list_broadcasts(
|
||||
|
||||
@router.get('/email-filters', response_model=EmailFiltersResponse)
|
||||
async def get_email_filters(
|
||||
admin: User = Depends(require_permission('broadcasts:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> EmailFiltersResponse:
|
||||
"""Get all available email filters with user counts."""
|
||||
@@ -534,7 +521,7 @@ async def get_email_filters(
|
||||
@router.post('/email-preview', response_model=EmailPreviewResponse)
|
||||
async def preview_email_broadcast(
|
||||
request: EmailPreviewRequest,
|
||||
admin: User = Depends(require_permission('broadcasts:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> EmailPreviewResponse:
|
||||
"""Preview email broadcast recipients count."""
|
||||
@@ -559,7 +546,7 @@ async def preview_email_broadcast(
|
||||
@router.post('/send', response_model=BroadcastResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_combined_broadcast(
|
||||
request: CombinedBroadcastCreateRequest,
|
||||
admin: User = Depends(require_permission('broadcasts:send')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> BroadcastResponse:
|
||||
"""Create and start a combined broadcast (telegram/email/both)."""
|
||||
@@ -629,7 +616,6 @@ async def create_combined_broadcast(
|
||||
status='queued',
|
||||
admin_id=admin.id,
|
||||
admin_name=admin_name,
|
||||
category=request.category,
|
||||
channel=request.channel,
|
||||
email_subject=request.email_subject.strip() if request.email_subject else None,
|
||||
email_html_content=request.email_html_content.strip() if request.email_html_content else None,
|
||||
@@ -656,8 +642,6 @@ async def create_combined_broadcast(
|
||||
selected_buttons=request.selected_buttons,
|
||||
media=media_config,
|
||||
initiator_name=admin_name,
|
||||
custom_buttons=[btn.model_dump() for btn in request.custom_buttons] if request.custom_buttons else None,
|
||||
category=request.category,
|
||||
)
|
||||
|
||||
await broadcast_service.start_broadcast(broadcast.id, telegram_config)
|
||||
@@ -693,7 +677,7 @@ async def create_combined_broadcast(
|
||||
@router.get('/{broadcast_id}', response_model=BroadcastResponse)
|
||||
async def get_broadcast(
|
||||
broadcast_id: int,
|
||||
admin: User = Depends(require_permission('broadcasts:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> BroadcastResponse:
|
||||
"""Get broadcast details."""
|
||||
@@ -709,7 +693,7 @@ async def get_broadcast(
|
||||
@router.post('/{broadcast_id}/stop', response_model=BroadcastResponse)
|
||||
async def stop_broadcast(
|
||||
broadcast_id: int,
|
||||
admin: User = Depends(require_permission('broadcasts:send')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> BroadcastResponse:
|
||||
"""Stop a running broadcast (telegram or email)."""
|
||||
@@ -740,7 +724,7 @@ async def stop_broadcast(
|
||||
broadcast.status = 'cancelling'
|
||||
else:
|
||||
broadcast.status = 'cancelled'
|
||||
broadcast.completed_at = datetime.now(UTC)
|
||||
broadcast.completed_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(broadcast)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ from app.utils.button_styles_cache import (
|
||||
load_button_styles_cache,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -112,7 +112,7 @@ def _build_response(styles: dict[str, dict]) -> ButtonStylesResponse:
|
||||
|
||||
@router.get('', response_model=ButtonStylesResponse)
|
||||
async def get_button_styles(
|
||||
_admin: User = Depends(require_permission('settings:read')),
|
||||
_admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Return current per-section button styles. Admin only."""
|
||||
@@ -145,7 +145,7 @@ async def get_button_styles(
|
||||
@router.patch('', response_model=ButtonStylesResponse)
|
||||
async def update_button_styles(
|
||||
payload: ButtonStylesUpdate,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Partially update per-section button styles. Admin only."""
|
||||
@@ -243,7 +243,7 @@ async def update_button_styles(
|
||||
|
||||
@router.post('/reset', response_model=ButtonStylesResponse)
|
||||
async def reset_button_styles(
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reset all button styles to defaults. Admin only."""
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
"""Admin routes for managing advertising campaigns in cabinet."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.cabinet.utils.links import get_campaign_deep_link, get_campaign_web_link
|
||||
from app.config import settings
|
||||
from app.database.crud.campaign import (
|
||||
create_campaign,
|
||||
delete_campaign,
|
||||
@@ -22,19 +20,14 @@ from app.database.crud.campaign import (
|
||||
from app.database.crud.server_squad import get_all_server_squads
|
||||
from app.database.crud.tariff import get_all_tariffs
|
||||
from app.database.models import (
|
||||
AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration,
|
||||
PartnerStatus,
|
||||
Subscription,
|
||||
Tariff,
|
||||
User,
|
||||
)
|
||||
from app.services.partner_stats_service import PartnerStatsService
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.campaigns import (
|
||||
AdminCampaignChartDataResponse,
|
||||
AvailablePartnerItem,
|
||||
CampaignCreateRequest,
|
||||
CampaignDetailResponse,
|
||||
CampaignListItem,
|
||||
@@ -56,59 +49,45 @@ logger = structlog.get_logger(__name__)
|
||||
router = APIRouter(prefix='/admin/campaigns', tags=['Cabinet Admin Campaigns'])
|
||||
|
||||
|
||||
def _safe_div(value: float | None, divisor: int = 100) -> float:
|
||||
"""Safely divide kopeks to rubles, handling None values."""
|
||||
return (value or 0) / divisor
|
||||
|
||||
|
||||
def _get_partner_name(campaign: AdvertisingCampaign) -> str | None:
|
||||
"""Get partner display name from campaign."""
|
||||
if not campaign.partner_user_id or not campaign.partner:
|
||||
return None
|
||||
partner = campaign.partner
|
||||
return partner.first_name or partner.username or f'#{partner.id}'
|
||||
def _get_deep_link(start_parameter: str) -> str:
|
||||
"""Generate deep link for campaign."""
|
||||
bot_username = settings.get_bot_username()
|
||||
if bot_username:
|
||||
return f'https://t.me/{bot_username}?start={start_parameter}'
|
||||
return f'?start={start_parameter}'
|
||||
|
||||
|
||||
@router.get('/overview', response_model=CampaignsOverviewResponse)
|
||||
async def get_overview(
|
||||
admin: User = Depends(require_permission('campaigns:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get campaigns overview statistics."""
|
||||
try:
|
||||
overview = await get_campaigns_overview(db)
|
||||
overview = await get_campaigns_overview(db)
|
||||
|
||||
# Count tariff bonuses
|
||||
tariff_result = await db.execute(
|
||||
select(func.count(AdvertisingCampaignRegistration.id)).where(
|
||||
AdvertisingCampaignRegistration.bonus_type == 'tariff'
|
||||
)
|
||||
# Count tariff bonuses
|
||||
tariff_result = await db.execute(
|
||||
select(func.count(AdvertisingCampaignRegistration.id)).where(
|
||||
AdvertisingCampaignRegistration.bonus_type == 'tariff'
|
||||
)
|
||||
tariff_count = tariff_result.scalar() or 0
|
||||
)
|
||||
tariff_count = tariff_result.scalar() or 0
|
||||
|
||||
return CampaignsOverviewResponse(
|
||||
total=overview['total'],
|
||||
active=overview['active'],
|
||||
inactive=overview['inactive'],
|
||||
total_registrations=overview['registrations'],
|
||||
total_balance_issued_kopeks=overview['balance_total'],
|
||||
total_balance_issued_rubles=_safe_div(overview['balance_total']),
|
||||
total_subscription_issued=overview['subscription_total'],
|
||||
total_tariff_issued=tariff_count,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error('Failed to get campaigns overview', error=str(e), exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load campaigns overview',
|
||||
)
|
||||
return CampaignsOverviewResponse(
|
||||
total=overview['total'],
|
||||
active=overview['active'],
|
||||
inactive=overview['inactive'],
|
||||
total_registrations=overview['registrations'],
|
||||
total_balance_issued_kopeks=overview['balance_total'],
|
||||
total_balance_issued_rubles=overview['balance_total'] / 100,
|
||||
total_subscription_issued=overview['subscription_total'],
|
||||
total_tariff_issued=tariff_count,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/available-servers', response_model=list[ServerSquadInfo])
|
||||
async def get_available_servers(
|
||||
admin: User = Depends(require_permission('campaigns:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of available server squads for campaign subscription bonus."""
|
||||
@@ -126,7 +105,7 @@ async def get_available_servers(
|
||||
|
||||
@router.get('/available-tariffs', response_model=list[TariffListItem])
|
||||
async def get_available_tariffs(
|
||||
admin: User = Depends(require_permission('campaigns:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of available tariffs for campaign tariff bonus."""
|
||||
@@ -153,37 +132,17 @@ async def get_available_tariffs(
|
||||
]
|
||||
|
||||
|
||||
@router.get('/available-partners', response_model=list[AvailablePartnerItem])
|
||||
async def get_available_partners(
|
||||
admin: User = Depends(require_permission('campaigns:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of approved partners for campaign partner selector."""
|
||||
result = await db.execute(
|
||||
select(User).where(User.partner_status == PartnerStatus.APPROVED.value).order_by(User.first_name, User.username)
|
||||
)
|
||||
partners = result.scalars().all()
|
||||
return [
|
||||
AvailablePartnerItem(
|
||||
user_id=p.id,
|
||||
username=p.username,
|
||||
first_name=p.first_name,
|
||||
)
|
||||
for p in partners
|
||||
]
|
||||
|
||||
|
||||
@router.get('', response_model=CampaignListResponse)
|
||||
async def list_campaigns(
|
||||
include_inactive: bool = True,
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
admin: User = Depends(require_permission('campaigns:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of all campaigns."""
|
||||
campaigns = await get_campaigns_list(db, offset=offset, limit=limit, include_inactive=include_inactive)
|
||||
total = await get_campaigns_count(db, is_active=True if not include_inactive else None)
|
||||
total = await get_campaigns_count(db)
|
||||
|
||||
items = []
|
||||
for campaign in campaigns:
|
||||
@@ -199,8 +158,6 @@ async def list_campaigns(
|
||||
registrations_count=stats['registrations'],
|
||||
total_revenue_kopeks=stats['total_revenue_kopeks'],
|
||||
conversion_rate=stats['conversion_rate'],
|
||||
partner_user_id=campaign.partner_user_id,
|
||||
partner_name=_get_partner_name(campaign),
|
||||
created_at=campaign.created_at,
|
||||
)
|
||||
)
|
||||
@@ -211,7 +168,7 @@ async def list_campaigns(
|
||||
@router.get('/{campaign_id}', response_model=CampaignDetailResponse)
|
||||
async def get_campaign(
|
||||
campaign_id: int,
|
||||
admin: User = Depends(require_permission('campaigns:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get detailed campaign info."""
|
||||
@@ -236,7 +193,7 @@ async def get_campaign(
|
||||
bonus_type=campaign.bonus_type,
|
||||
is_active=campaign.is_active,
|
||||
balance_bonus_kopeks=campaign.balance_bonus_kopeks or 0,
|
||||
balance_bonus_rubles=_safe_div(campaign.balance_bonus_kopeks),
|
||||
balance_bonus_rubles=(campaign.balance_bonus_kopeks or 0) / 100,
|
||||
subscription_duration_days=campaign.subscription_duration_days,
|
||||
subscription_traffic_gb=campaign.subscription_traffic_gb,
|
||||
subscription_device_limit=campaign.subscription_device_limit,
|
||||
@@ -244,102 +201,62 @@ async def get_campaign(
|
||||
tariff_id=campaign.tariff_id,
|
||||
tariff_duration_days=campaign.tariff_duration_days,
|
||||
tariff=tariff_info,
|
||||
partner_user_id=campaign.partner_user_id,
|
||||
partner_name=_get_partner_name(campaign),
|
||||
created_by=campaign.created_by,
|
||||
created_at=campaign.created_at,
|
||||
updated_at=campaign.updated_at,
|
||||
deep_link=get_campaign_deep_link(campaign.start_parameter),
|
||||
web_link=get_campaign_web_link(campaign.start_parameter),
|
||||
deep_link=_get_deep_link(campaign.start_parameter),
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{campaign_id}/chart-data', response_model=AdminCampaignChartDataResponse)
|
||||
async def get_campaign_chart_data(
|
||||
campaign_id: int,
|
||||
admin: User = Depends(require_permission('campaigns:stats')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get chart data for admin campaign analytics."""
|
||||
try:
|
||||
campaign = await get_campaign_by_id(db, campaign_id)
|
||||
if not campaign:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Campaign not found',
|
||||
)
|
||||
|
||||
data = await PartnerStatsService.get_admin_campaign_chart_data(db, campaign_id)
|
||||
return AdminCampaignChartDataResponse(**data)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error('Failed to get campaign chart data', error=str(e), campaign_id=campaign_id, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load campaign chart data',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{campaign_id}/stats', response_model=CampaignStatisticsResponse)
|
||||
async def get_campaign_stats(
|
||||
campaign_id: int,
|
||||
admin: User = Depends(require_permission('campaigns:stats')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get detailed campaign statistics."""
|
||||
try:
|
||||
campaign = await get_campaign_by_id(db, campaign_id)
|
||||
if not campaign:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Campaign not found',
|
||||
)
|
||||
|
||||
stats = await get_campaign_statistics(db, campaign_id)
|
||||
|
||||
return CampaignStatisticsResponse(
|
||||
id=campaign.id,
|
||||
name=campaign.name,
|
||||
start_parameter=campaign.start_parameter,
|
||||
bonus_type=campaign.bonus_type,
|
||||
is_active=campaign.is_active,
|
||||
registrations=stats['registrations'],
|
||||
balance_issued_kopeks=stats['balance_issued'],
|
||||
balance_issued_rubles=_safe_div(stats['balance_issued']),
|
||||
subscription_issued=stats['subscription_issued'],
|
||||
last_registration=stats['last_registration'],
|
||||
total_revenue_kopeks=stats['total_revenue_kopeks'],
|
||||
total_revenue_rubles=_safe_div(stats['total_revenue_kopeks']),
|
||||
avg_revenue_per_user_kopeks=stats['avg_revenue_per_user_kopeks'],
|
||||
avg_revenue_per_user_rubles=_safe_div(stats['avg_revenue_per_user_kopeks']),
|
||||
avg_first_payment_kopeks=stats['avg_first_payment_kopeks'],
|
||||
avg_first_payment_rubles=_safe_div(stats['avg_first_payment_kopeks']),
|
||||
trial_users_count=stats['trial_users_count'],
|
||||
active_trials_count=stats['active_trials_count'],
|
||||
conversion_count=stats['conversion_count'],
|
||||
paid_users_count=stats['paid_users_count'],
|
||||
conversion_rate=stats['conversion_rate'],
|
||||
trial_conversion_rate=stats['trial_conversion_rate'],
|
||||
deep_link=get_campaign_deep_link(campaign.start_parameter),
|
||||
web_link=get_campaign_web_link(campaign.start_parameter),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error('Failed to get campaign stats', error=str(e), campaign_id=campaign_id, exc_info=True)
|
||||
campaign = await get_campaign_by_id(db, campaign_id)
|
||||
if not campaign:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load campaign statistics',
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Campaign not found',
|
||||
)
|
||||
|
||||
stats = await get_campaign_statistics(db, campaign_id)
|
||||
|
||||
return CampaignStatisticsResponse(
|
||||
id=campaign.id,
|
||||
name=campaign.name,
|
||||
start_parameter=campaign.start_parameter,
|
||||
bonus_type=campaign.bonus_type,
|
||||
is_active=campaign.is_active,
|
||||
registrations=stats['registrations'],
|
||||
balance_issued_kopeks=stats['balance_issued'],
|
||||
balance_issued_rubles=stats['balance_issued'] / 100,
|
||||
subscription_issued=stats['subscription_issued'],
|
||||
last_registration=stats['last_registration'],
|
||||
total_revenue_kopeks=stats['total_revenue_kopeks'],
|
||||
total_revenue_rubles=stats['total_revenue_kopeks'] / 100,
|
||||
avg_revenue_per_user_kopeks=stats['avg_revenue_per_user_kopeks'],
|
||||
avg_revenue_per_user_rubles=stats['avg_revenue_per_user_kopeks'] / 100,
|
||||
avg_first_payment_kopeks=stats['avg_first_payment_kopeks'],
|
||||
avg_first_payment_rubles=stats['avg_first_payment_kopeks'] / 100,
|
||||
trial_users_count=stats['trial_users_count'],
|
||||
active_trials_count=stats['active_trials_count'],
|
||||
conversion_count=stats['conversion_count'],
|
||||
paid_users_count=stats['paid_users_count'],
|
||||
conversion_rate=stats['conversion_rate'],
|
||||
trial_conversion_rate=stats['trial_conversion_rate'],
|
||||
deep_link=_get_deep_link(campaign.start_parameter),
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{campaign_id}/registrations', response_model=CampaignRegistrationsResponse)
|
||||
async def get_campaign_registrations(
|
||||
campaign_id: int,
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(50, ge=1, le=100),
|
||||
admin: User = Depends(require_permission('campaigns:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of users registered through campaign."""
|
||||
@@ -371,22 +288,19 @@ async def get_campaign_registrations(
|
||||
)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
# Batch query: find which users have active subscriptions (avoids N+1)
|
||||
user_ids = [user.id for _reg, user in rows]
|
||||
active_sub_user_ids: set[int] = set()
|
||||
if user_ids:
|
||||
sub_result = await db.execute(
|
||||
select(Subscription.user_id)
|
||||
.where(
|
||||
Subscription.user_id.in_(user_ids),
|
||||
Subscription.status == 'active',
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
active_sub_user_ids = set(sub_result.scalars().all())
|
||||
|
||||
items = []
|
||||
for reg, user in rows:
|
||||
# Check if user has subscription
|
||||
sub_result = await db.execute(
|
||||
select(Subscription)
|
||||
.where(
|
||||
Subscription.user_id == user.id,
|
||||
Subscription.status == 'active',
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
has_sub = sub_result.scalar_one_or_none() is not None
|
||||
|
||||
items.append(
|
||||
CampaignRegistrationItem(
|
||||
id=reg.id,
|
||||
@@ -401,7 +315,7 @@ async def get_campaign_registrations(
|
||||
tariff_duration_days=reg.tariff_duration_days,
|
||||
created_at=reg.created_at,
|
||||
user_balance_kopeks=user.balance_kopeks or 0,
|
||||
has_subscription=user.id in active_sub_user_ids,
|
||||
has_subscription=has_sub,
|
||||
has_paid=user.has_had_paid_subscription or False,
|
||||
)
|
||||
)
|
||||
@@ -417,7 +331,7 @@ async def get_campaign_registrations(
|
||||
@router.post('', response_model=CampaignDetailResponse)
|
||||
async def create_new_campaign(
|
||||
request: CampaignCreateRequest,
|
||||
admin: User = Depends(require_permission('campaigns:create')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Create a new advertising campaign."""
|
||||
@@ -444,15 +358,6 @@ async def create_new_campaign(
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Validate partner exists and is approved
|
||||
if request.partner_user_id is not None:
|
||||
partner_user = await db.get(User, request.partner_user_id)
|
||||
if not partner_user or partner_user.partner_status != PartnerStatus.APPROVED.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Partner not found or not approved',
|
||||
)
|
||||
|
||||
campaign = await create_campaign(
|
||||
db,
|
||||
name=request.name,
|
||||
@@ -467,9 +372,11 @@ async def create_new_campaign(
|
||||
tariff_id=request.tariff_id,
|
||||
tariff_duration_days=request.tariff_duration_days,
|
||||
is_active=request.is_active,
|
||||
partner_user_id=request.partner_user_id,
|
||||
)
|
||||
|
||||
# Reload to get tariff relationship
|
||||
campaign = await get_campaign_by_id(db, campaign.id)
|
||||
|
||||
logger.info('Admin created campaign', admin_id=admin.id, campaign_id=campaign.id, campaign_name=campaign.name)
|
||||
|
||||
return await get_campaign(campaign.id, admin, db)
|
||||
@@ -479,7 +386,7 @@ async def create_new_campaign(
|
||||
async def update_existing_campaign(
|
||||
campaign_id: int,
|
||||
request: CampaignUpdateRequest,
|
||||
admin: User = Depends(require_permission('campaigns:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update an existing campaign."""
|
||||
@@ -511,51 +418,33 @@ async def update_existing_campaign(
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Build updates using model_fields_set to distinguish "not sent" from "sent as None"
|
||||
# Build updates
|
||||
updates = {}
|
||||
if 'name' in request.model_fields_set:
|
||||
if request.name is not None:
|
||||
updates['name'] = request.name
|
||||
if 'start_parameter' in request.model_fields_set:
|
||||
if request.start_parameter is not None:
|
||||
updates['start_parameter'] = request.start_parameter
|
||||
if 'bonus_type' in request.model_fields_set:
|
||||
if request.bonus_type is not None:
|
||||
updates['bonus_type'] = request.bonus_type
|
||||
if 'is_active' in request.model_fields_set:
|
||||
if request.is_active is not None:
|
||||
updates['is_active'] = request.is_active
|
||||
if 'balance_bonus_kopeks' in request.model_fields_set:
|
||||
if request.balance_bonus_kopeks is not None:
|
||||
updates['balance_bonus_kopeks'] = request.balance_bonus_kopeks
|
||||
if 'subscription_duration_days' in request.model_fields_set:
|
||||
if request.subscription_duration_days is not None:
|
||||
updates['subscription_duration_days'] = request.subscription_duration_days
|
||||
if 'subscription_traffic_gb' in request.model_fields_set:
|
||||
if request.subscription_traffic_gb is not None:
|
||||
updates['subscription_traffic_gb'] = request.subscription_traffic_gb
|
||||
if 'subscription_device_limit' in request.model_fields_set:
|
||||
if request.subscription_device_limit is not None:
|
||||
updates['subscription_device_limit'] = request.subscription_device_limit
|
||||
if 'subscription_squads' in request.model_fields_set:
|
||||
if request.subscription_squads is not None:
|
||||
updates['subscription_squads'] = request.subscription_squads
|
||||
if 'tariff_id' in request.model_fields_set:
|
||||
if request.tariff_id is not None:
|
||||
updates['tariff_id'] = request.tariff_id
|
||||
if 'tariff_duration_days' in request.model_fields_set:
|
||||
if request.tariff_duration_days is not None:
|
||||
updates['tariff_duration_days'] = request.tariff_duration_days
|
||||
|
||||
# Handle partner_user_id separately (allows explicit None to unassign)
|
||||
partner_changed = False
|
||||
if 'partner_user_id' in request.model_fields_set:
|
||||
new_partner_id = request.partner_user_id
|
||||
if new_partner_id is not None:
|
||||
partner_user = await db.get(User, new_partner_id)
|
||||
if not partner_user or partner_user.partner_status != PartnerStatus.APPROVED.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Partner not found or not approved',
|
||||
)
|
||||
campaign.partner_user_id = new_partner_id
|
||||
campaign.updated_at = datetime.now(UTC)
|
||||
partner_changed = True
|
||||
|
||||
if updates:
|
||||
await update_campaign(db, campaign, **updates)
|
||||
elif partner_changed:
|
||||
await db.commit()
|
||||
await db.refresh(campaign)
|
||||
|
||||
logger.info('Admin updated campaign', admin_id=admin.id, campaign_id=campaign_id)
|
||||
|
||||
@@ -565,7 +454,7 @@ async def update_existing_campaign(
|
||||
@router.delete('/{campaign_id}')
|
||||
async def delete_existing_campaign(
|
||||
campaign_id: int,
|
||||
admin: User = Depends(require_permission('campaigns:delete')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Delete a campaign."""
|
||||
@@ -576,13 +465,8 @@ async def delete_existing_campaign(
|
||||
detail='Campaign not found',
|
||||
)
|
||||
|
||||
# Check if campaign has registrations (COUNT query instead of loading all)
|
||||
reg_count_result = await db.execute(
|
||||
select(func.count(AdvertisingCampaignRegistration.id)).where(
|
||||
AdvertisingCampaignRegistration.campaign_id == campaign_id
|
||||
)
|
||||
)
|
||||
reg_count = reg_count_result.scalar() or 0
|
||||
# Check if campaign has registrations
|
||||
reg_count = len(campaign.registrations) if campaign.registrations else 0
|
||||
if reg_count > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -598,7 +482,7 @@ async def delete_existing_campaign(
|
||||
@router.post('/{campaign_id}/toggle', response_model=CampaignToggleResponse)
|
||||
async def toggle_campaign(
|
||||
campaign_id: int,
|
||||
admin: User = Depends(require_permission('campaigns:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Toggle campaign active status."""
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
"""Admin API for managing required channels."""
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.required_channel import (
|
||||
add_channel,
|
||||
delete_channel,
|
||||
get_all_channels,
|
||||
toggle_channel,
|
||||
update_channel,
|
||||
)
|
||||
from app.database.models import User
|
||||
from app.services.channel_subscription_service import channel_subscription_service
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.channel import (
|
||||
ChannelCreateRequest,
|
||||
ChannelListResponse,
|
||||
ChannelResponse,
|
||||
ChannelUpdateRequest,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/channel-subscriptions', tags=['Cabinet Admin Channels'])
|
||||
|
||||
|
||||
@router.get('', response_model=ChannelListResponse)
|
||||
async def list_channels(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
_admin: User = Depends(require_permission('channels:read')),
|
||||
) -> ChannelListResponse:
|
||||
channels = await get_all_channels(db)
|
||||
return ChannelListResponse(
|
||||
items=[ChannelResponse.model_validate(ch) for ch in channels],
|
||||
total=len(channels),
|
||||
)
|
||||
|
||||
|
||||
@router.post('', response_model=ChannelResponse, status_code=201)
|
||||
async def create_channel(
|
||||
data: ChannelCreateRequest,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
_admin: User = Depends(require_permission('channels:edit')),
|
||||
) -> ChannelResponse:
|
||||
ch = await add_channel(
|
||||
db,
|
||||
channel_id=data.channel_id,
|
||||
channel_link=data.channel_link,
|
||||
title=data.title,
|
||||
disable_trial_on_leave=data.disable_trial_on_leave,
|
||||
disable_paid_on_leave=data.disable_paid_on_leave,
|
||||
)
|
||||
await channel_subscription_service.invalidate_channels_cache()
|
||||
return ChannelResponse.model_validate(ch)
|
||||
|
||||
|
||||
@router.patch('/{channel_db_id}', response_model=ChannelResponse)
|
||||
async def update_channel_endpoint(
|
||||
channel_db_id: int,
|
||||
data: ChannelUpdateRequest,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
_admin: User = Depends(require_permission('channels:edit')),
|
||||
) -> ChannelResponse:
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
ch = await update_channel(db, channel_db_id, **update_data)
|
||||
if not ch:
|
||||
raise HTTPException(status_code=404, detail='Channel not found')
|
||||
await channel_subscription_service.invalidate_channels_cache()
|
||||
return ChannelResponse.model_validate(ch)
|
||||
|
||||
|
||||
@router.post('/{channel_db_id}/toggle', response_model=ChannelResponse)
|
||||
async def toggle_channel_endpoint(
|
||||
channel_db_id: int,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
_admin: User = Depends(require_permission('channels:edit')),
|
||||
) -> ChannelResponse:
|
||||
ch = await toggle_channel(db, channel_db_id)
|
||||
if not ch:
|
||||
raise HTTPException(status_code=404, detail='Channel not found')
|
||||
await channel_subscription_service.invalidate_channels_cache()
|
||||
return ChannelResponse.model_validate(ch)
|
||||
|
||||
|
||||
@router.delete('/{channel_db_id}', status_code=204)
|
||||
async def delete_channel_endpoint(
|
||||
channel_db_id: int,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
_admin: User = Depends(require_permission('channels:edit')),
|
||||
) -> None:
|
||||
ok = await delete_channel(db, channel_db_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail='Channel not found')
|
||||
await channel_subscription_service.invalidate_channels_cache()
|
||||
@@ -10,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..services.email_template_overrides import (
|
||||
delete_template_override,
|
||||
get_all_overrides,
|
||||
@@ -37,7 +37,7 @@ TEMPLATE_TYPES = [
|
||||
'zh': '余额充值通知',
|
||||
'ua': 'Сповіщення про поповнення балансу',
|
||||
},
|
||||
'context_vars': ['formatted_amount', 'formatted_balance', 'amount_rubles', 'new_balance_rubles'],
|
||||
'context_vars': ['amount', 'balance'],
|
||||
},
|
||||
{
|
||||
'type': 'balance_change',
|
||||
@@ -48,7 +48,7 @@ TEMPLATE_TYPES = [
|
||||
'zh': '余额变动通知',
|
||||
'ua': 'Сповіщення про зміну балансу',
|
||||
},
|
||||
'context_vars': ['formatted_amount', 'formatted_balance', 'amount_rubles', 'new_balance_rubles'],
|
||||
'context_vars': ['amount', 'balance'],
|
||||
},
|
||||
{
|
||||
'type': 'subscription_expiring',
|
||||
@@ -96,7 +96,7 @@ TEMPLATE_TYPES = [
|
||||
'zh': '订阅已续期通知',
|
||||
'ua': 'Сповіщення про продовження підписки',
|
||||
},
|
||||
'context_vars': ['new_expires_at', 'tariff_name', 'traffic_limit_gb', 'device_limit'],
|
||||
'context_vars': ['new_end_date', 'tariff_name'],
|
||||
},
|
||||
{
|
||||
'type': 'subscription_activated',
|
||||
@@ -112,7 +112,7 @@ TEMPLATE_TYPES = [
|
||||
'zh': '订阅已激活通知',
|
||||
'ua': 'Сповіщення про активацію підписки',
|
||||
},
|
||||
'context_vars': ['expires_at', 'tariff_name', 'traffic_limit_gb', 'device_limit'],
|
||||
'context_vars': ['tariff_name', 'end_date'],
|
||||
},
|
||||
{
|
||||
'type': 'autopay_success',
|
||||
@@ -128,7 +128,7 @@ TEMPLATE_TYPES = [
|
||||
'zh': '自动续费成功通知',
|
||||
'ua': 'Сповіщення про успішний автоплатіж',
|
||||
},
|
||||
'context_vars': ['formatted_amount', 'amount_rubles', 'new_expires_at'],
|
||||
'context_vars': ['amount', 'balance', 'new_end_date'],
|
||||
},
|
||||
{
|
||||
'type': 'autopay_failed',
|
||||
@@ -160,7 +160,7 @@ TEMPLATE_TYPES = [
|
||||
'zh': '自动续费余额不足通知',
|
||||
'ua': 'Сповіщення про нестачу коштів для автоплатежу',
|
||||
},
|
||||
'context_vars': ['required_amount', 'current_balance'],
|
||||
'context_vars': ['required_amount', 'balance'],
|
||||
},
|
||||
{
|
||||
'type': 'daily_debit',
|
||||
@@ -171,7 +171,7 @@ TEMPLATE_TYPES = [
|
||||
'zh': '每日扣费通知',
|
||||
'ua': 'Сповіщення про добове списання',
|
||||
},
|
||||
'context_vars': ['formatted_amount', 'formatted_balance', 'amount_rubles', 'new_balance_rubles'],
|
||||
'context_vars': ['amount', 'balance'],
|
||||
},
|
||||
{
|
||||
'type': 'daily_insufficient_funds',
|
||||
@@ -187,7 +187,7 @@ TEMPLATE_TYPES = [
|
||||
'zh': '每日扣费余额不足通知',
|
||||
'ua': 'Сповіщення про нестачу коштів для добового списання',
|
||||
},
|
||||
'context_vars': ['required_amount', 'current_balance'],
|
||||
'context_vars': ['required_amount', 'balance'],
|
||||
},
|
||||
{
|
||||
'type': 'ban_notification',
|
||||
@@ -236,7 +236,7 @@ TEMPLATE_TYPES = [
|
||||
'zh': '推荐奖励通知',
|
||||
'ua': 'Сповіщення про нарахування реферального бонусу',
|
||||
},
|
||||
'context_vars': ['formatted_bonus', 'bonus_rubles', 'referral_name'],
|
||||
'context_vars': ['amount', 'referral_name'],
|
||||
},
|
||||
{
|
||||
'type': 'referral_registered',
|
||||
@@ -258,7 +258,7 @@ TEMPLATE_TYPES = [
|
||||
'zh': '流量重置通知',
|
||||
'ua': 'Сповіщення про скидання трафіку',
|
||||
},
|
||||
'context_vars': ['reset_gb', 'current_limit_gb'],
|
||||
'context_vars': ['traffic_limit'],
|
||||
},
|
||||
{
|
||||
'type': 'payment_received',
|
||||
@@ -269,7 +269,7 @@ TEMPLATE_TYPES = [
|
||||
'zh': '收到付款通知',
|
||||
'ua': 'Сповіщення про отримання платежу',
|
||||
},
|
||||
'context_vars': ['formatted_amount', 'payment_method'],
|
||||
'context_vars': ['amount', 'payment_method'],
|
||||
},
|
||||
{
|
||||
'type': 'email_verification',
|
||||
@@ -298,77 +298,6 @@ TEMPLATE_TYPES = [
|
||||
},
|
||||
'context_vars': ['username', 'reset_url', 'expire_hours'],
|
||||
},
|
||||
{
|
||||
'type': 'guest_subscription_delivered',
|
||||
'label': {
|
||||
'ru': 'Быстрая покупка: подписка доставлена',
|
||||
'en': 'Quick Purchase: Subscription Delivered',
|
||||
'zh': '快捷购买:订阅已交付',
|
||||
'ua': 'Швидка покупка: підписка доставлена',
|
||||
},
|
||||
'description': {
|
||||
'ru': 'Письмо покупателю после успешной оплаты через лендинг',
|
||||
'en': 'Email to buyer after successful landing page payment',
|
||||
'zh': '通过落地页成功付款后发送给买家的邮件',
|
||||
'ua': 'Лист покупцю після успішної оплати через лендінг',
|
||||
},
|
||||
'context_vars': ['tariff_name', 'period_days', 'cabinet_url', 'cabinet_email', 'cabinet_password'],
|
||||
},
|
||||
{
|
||||
'type': 'guest_activation_required',
|
||||
'label': {
|
||||
'ru': 'Быстрая покупка: требуется активация',
|
||||
'en': 'Quick Purchase: Activation Required',
|
||||
'zh': '快捷购买:需要激活',
|
||||
'ua': 'Швидка покупка: потрібна активація',
|
||||
},
|
||||
'description': {
|
||||
'ru': 'Письмо когда у покупателя уже есть активная подписка',
|
||||
'en': 'Email when buyer already has an active subscription',
|
||||
'zh': '买家已有活跃订阅时发送的邮件',
|
||||
'ua': 'Лист коли у покупця вже є активна підписка',
|
||||
},
|
||||
'context_vars': ['tariff_name', 'period_days', 'success_page_url', 'gift_message', 'is_gift'],
|
||||
},
|
||||
{
|
||||
'type': 'guest_gift_received',
|
||||
'label': {
|
||||
'ru': 'Быстрая покупка: подарок получен',
|
||||
'en': 'Quick Purchase: Gift Received',
|
||||
'zh': '快捷购买:收到礼物',
|
||||
'ua': 'Швидка покупка: подарунок отримано',
|
||||
},
|
||||
'description': {
|
||||
'ru': 'Письмо получателю подарочной подписки',
|
||||
'en': 'Email to gift subscription recipient',
|
||||
'zh': '发送给礼物订阅接收者的邮件',
|
||||
'ua': 'Лист отримувачу подарункової підписки',
|
||||
},
|
||||
'context_vars': [
|
||||
'tariff_name',
|
||||
'period_days',
|
||||
'cabinet_url',
|
||||
'gift_message',
|
||||
'cabinet_email',
|
||||
'cabinet_password',
|
||||
],
|
||||
},
|
||||
{
|
||||
'type': 'guest_cabinet_credentials',
|
||||
'label': {
|
||||
'ru': 'Быстрая покупка: данные для входа',
|
||||
'en': 'Quick Purchase: Login Credentials',
|
||||
'zh': '快捷购买:登录凭据',
|
||||
'ua': 'Швидка покупка: дані для входу',
|
||||
},
|
||||
'description': {
|
||||
'ru': 'Письмо с логином и паролем для личного кабинета',
|
||||
'en': 'Email with login credentials for the cabinet',
|
||||
'zh': '包含个人中心登录信息的邮件',
|
||||
'ua': 'Лист з логіном та паролем для особистого кабінету',
|
||||
},
|
||||
'context_vars': ['tariff_name', 'period_days', 'cabinet_url', 'cabinet_email', 'cabinet_password'],
|
||||
},
|
||||
]
|
||||
|
||||
SAMPLE_CONTEXTS: dict[str, dict[str, Any]] = {
|
||||
@@ -386,70 +315,26 @@ SAMPLE_CONTEXTS: dict[str, dict[str, Any]] = {
|
||||
},
|
||||
'subscription_expiring': {'days_left': 3, 'expires_at': '2025-01-30'},
|
||||
'subscription_expired': {},
|
||||
'subscription_renewed': {
|
||||
'new_expires_at': '2025-02-28',
|
||||
'tariff_name': 'Premium',
|
||||
'traffic_limit_gb': 100,
|
||||
'device_limit': 3,
|
||||
},
|
||||
'subscription_activated': {
|
||||
'expires_at': '2025-02-28',
|
||||
'tariff_name': 'Premium',
|
||||
'traffic_limit_gb': 100,
|
||||
'device_limit': 3,
|
||||
},
|
||||
'autopay_success': {'formatted_amount': '300.00 ₽', 'amount_rubles': 300, 'new_expires_at': '2025-02-28'},
|
||||
'subscription_renewed': {'new_end_date': '2025-02-28', 'tariff_name': 'Premium'},
|
||||
'subscription_activated': {'tariff_name': 'Premium', 'end_date': '2025-02-28'},
|
||||
'autopay_success': {'formatted_amount': '300.00 ₽', 'formatted_balance': '200.00 ₽', 'new_end_date': '2025-02-28'},
|
||||
'autopay_failed': {'reason': 'Card declined'},
|
||||
'autopay_insufficient_funds': {'required_amount': '300.00 ₽', 'current_balance': '50.00 ₽'},
|
||||
'daily_debit': {
|
||||
'formatted_amount': '10.00 ₽',
|
||||
'formatted_balance': '490.00 ₽',
|
||||
'amount_rubles': 10,
|
||||
'new_balance_rubles': 490,
|
||||
},
|
||||
'daily_insufficient_funds': {'required_amount': '10.00 ₽', 'current_balance': '5.00 ₽'},
|
||||
'autopay_insufficient_funds': {'formatted_required': '300.00 ₽', 'formatted_balance': '50.00 ₽'},
|
||||
'daily_debit': {'formatted_amount': '10.00 ₽', 'formatted_balance': '490.00 ₽'},
|
||||
'daily_insufficient_funds': {'formatted_required': '10.00 ₽', 'formatted_balance': '5.00 ₽'},
|
||||
'ban_notification': {'reason': 'Violation of terms of service'},
|
||||
'unban_notification': {},
|
||||
'warning_notification': {'message': 'Please review our terms of service'},
|
||||
'referral_bonus': {'formatted_bonus': '100.00 ₽', 'bonus_rubles': 100, 'referral_name': 'John'},
|
||||
'referral_bonus': {'formatted_amount': '100.00 ₽', 'referral_name': 'John'},
|
||||
'referral_registered': {'referral_name': 'John'},
|
||||
'traffic_reset': {'reset_gb': 50, 'current_limit_gb': 100},
|
||||
'payment_received': {'formatted_amount': '500.00 ₽', 'amount_rubles': 500, 'payment_method': 'YooKassa'},
|
||||
'traffic_reset': {'traffic_limit': '100 GB'},
|
||||
'payment_received': {'formatted_amount': '500.00 ₽', 'payment_method': 'YooKassa'},
|
||||
'email_verification': {
|
||||
'username': 'John',
|
||||
'verification_url': 'https://example.com/verify?token=abc123',
|
||||
'expire_hours': 24,
|
||||
},
|
||||
'password_reset': {'username': 'John', 'reset_url': 'https://example.com/reset?token=abc123', 'expire_hours': 1},
|
||||
'guest_subscription_delivered': {
|
||||
'tariff_name': 'Premium',
|
||||
'period_days': 30,
|
||||
'cabinet_url': 'https://example.com/cabinet',
|
||||
'cabinet_email': 'user@example.com',
|
||||
'cabinet_password': 'SecurePass123',
|
||||
},
|
||||
'guest_activation_required': {
|
||||
'tariff_name': 'Premium',
|
||||
'period_days': 30,
|
||||
'success_page_url': 'https://example.com/cabinet/buy/success/abc123',
|
||||
'is_gift': True,
|
||||
'gift_message': 'Happy birthday!',
|
||||
},
|
||||
'guest_gift_received': {
|
||||
'tariff_name': 'Premium',
|
||||
'period_days': 30,
|
||||
'cabinet_url': 'https://example.com/cabinet',
|
||||
'gift_message': 'Happy birthday!',
|
||||
'cabinet_email': 'recipient@example.com',
|
||||
'cabinet_password': 'SecurePass123',
|
||||
},
|
||||
'guest_cabinet_credentials': {
|
||||
'tariff_name': 'Premium',
|
||||
'period_days': 30,
|
||||
'cabinet_url': 'https://example.com/cabinet',
|
||||
'cabinet_email': 'user@example.com',
|
||||
'cabinet_password': 'SecurePass123',
|
||||
},
|
||||
}
|
||||
|
||||
AVAILABLE_LANGUAGES = ['ru', 'en', 'zh', 'ua', 'fa']
|
||||
@@ -485,7 +370,7 @@ class EmailTemplateSendTestRequest(BaseModel):
|
||||
|
||||
@router.get('', summary='List all email template types')
|
||||
async def list_template_types(
|
||||
_admin: User = Depends(require_permission('email_templates:read')),
|
||||
_admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict[str, Any]:
|
||||
"""List all available email template types with override status."""
|
||||
@@ -520,7 +405,7 @@ async def list_template_types(
|
||||
@router.get('/{notification_type}', summary='Get templates for a notification type')
|
||||
async def get_templates_for_type(
|
||||
notification_type: str,
|
||||
_admin: User = Depends(require_permission('email_templates:read')),
|
||||
_admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict[str, Any]:
|
||||
"""Get all language templates for a specific notification type."""
|
||||
@@ -594,7 +479,7 @@ async def update_template(
|
||||
notification_type: str,
|
||||
language: str,
|
||||
data: EmailTemplateUpdate,
|
||||
admin: User = Depends(require_permission('email_templates:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict[str, Any]:
|
||||
"""Save a custom email template override."""
|
||||
@@ -630,7 +515,7 @@ async def update_template(
|
||||
async def reset_template(
|
||||
notification_type: str,
|
||||
language: str,
|
||||
admin: User = Depends(require_permission('email_templates:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict[str, Any]:
|
||||
"""Delete custom template override, reverting to default."""
|
||||
@@ -658,7 +543,7 @@ async def reset_template(
|
||||
async def preview_template(
|
||||
notification_type: str,
|
||||
data: EmailTemplatePreviewRequest,
|
||||
_admin: User = Depends(require_permission('email_templates:read')),
|
||||
_admin: User = Depends(get_current_admin_user),
|
||||
) -> dict[str, Any]:
|
||||
"""Preview a rendered email template with sample data."""
|
||||
valid_types = [t['type'] for t in TEMPLATE_TYPES]
|
||||
@@ -672,8 +557,8 @@ async def preview_template(
|
||||
language = data.language if data.language in AVAILABLE_LANGUAGES else 'ru'
|
||||
|
||||
if data.body_html:
|
||||
# Preview custom content — auto-detects styled vs simple HTML
|
||||
rendered_html = templates_instance._wrap_override_template(data.body_html, language)
|
||||
# Preview custom content wrapped in base template
|
||||
rendered_html = templates_instance._get_base_template(data.body_html, language)
|
||||
subject = data.subject or notification_type
|
||||
else:
|
||||
# Preview default template
|
||||
@@ -703,7 +588,7 @@ async def preview_template(
|
||||
async def send_test_email(
|
||||
notification_type: str,
|
||||
data: EmailTemplateSendTestRequest,
|
||||
admin: User = Depends(require_permission('email_templates:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict[str, Any]:
|
||||
"""Send a test email to the admin's email address."""
|
||||
@@ -733,13 +618,14 @@ async def send_test_email(
|
||||
sample_context = SAMPLE_CONTEXTS.get(notification_type, {})
|
||||
templates_instance = EmailNotificationTemplates()
|
||||
|
||||
# Check for DB override (get_rendered_override substitutes sample context vars)
|
||||
from ..services.email_template_overrides import get_rendered_override
|
||||
# Check for DB override
|
||||
from ..services.email_template_overrides import get_template_override
|
||||
|
||||
rendered = await get_rendered_override(notification_type, language, sample_context, db)
|
||||
override = await get_template_override(notification_type, language, db)
|
||||
|
||||
if rendered:
|
||||
subject, body_html = rendered
|
||||
if override:
|
||||
subject = override['subject']
|
||||
body_html = templates_instance._get_base_template(override['body_html'], language)
|
||||
else:
|
||||
try:
|
||||
from app.services.notification_delivery_service import NotificationType
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
"""Admin routes for managing info pages in cabinet."""
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.info_pages import (
|
||||
clear_replaces_tab,
|
||||
create_info_page,
|
||||
delete_info_page,
|
||||
get_all_info_pages,
|
||||
get_info_page_by_id,
|
||||
reorder_info_pages,
|
||||
update_info_page,
|
||||
)
|
||||
from app.database.models import User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.info_pages import (
|
||||
InfoPageCreateRequest,
|
||||
InfoPageListItem,
|
||||
InfoPageResponse,
|
||||
InfoPageUpdateRequest,
|
||||
ReorderRequest,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/info-pages', tags=['Cabinet Admin Info Pages'])
|
||||
|
||||
|
||||
@router.get('', response_model=list[InfoPageListItem])
|
||||
async def list_all_info_pages(
|
||||
page_type: str | None = Query(None, pattern=r'^(page|faq)$'),
|
||||
admin: User = Depends(require_permission('info_pages:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> list[InfoPageListItem]:
|
||||
"""Get all info pages (admin view, includes inactive)."""
|
||||
try:
|
||||
pages = await get_all_info_pages(db, include_inactive=True, page_type=page_type)
|
||||
return [InfoPageListItem.model_validate(p) for p in pages]
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception('Failed to list info pages')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load info pages',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{page_id}', response_model=InfoPageResponse)
|
||||
async def get_info_page_detail(
|
||||
page_id: int,
|
||||
admin: User = Depends(require_permission('info_pages:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> InfoPageResponse:
|
||||
"""Get a single info page by ID (admin view)."""
|
||||
page = await get_info_page_by_id(db, page_id)
|
||||
if not page:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Info page not found',
|
||||
)
|
||||
return InfoPageResponse.model_validate(page)
|
||||
|
||||
|
||||
@router.post('', response_model=InfoPageResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_page(
|
||||
request: InfoPageCreateRequest,
|
||||
admin: User = Depends(require_permission('info_pages:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> InfoPageResponse:
|
||||
"""Create a new info page."""
|
||||
try:
|
||||
if request.replaces_tab:
|
||||
await clear_replaces_tab(db, request.replaces_tab)
|
||||
|
||||
page = await create_info_page(
|
||||
db,
|
||||
slug=request.slug,
|
||||
title=request.title,
|
||||
content=request.content,
|
||||
page_type=request.page_type,
|
||||
is_active=request.is_active,
|
||||
sort_order=request.sort_order,
|
||||
icon=request.icon,
|
||||
replaces_tab=request.replaces_tab,
|
||||
)
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='An info page with this slug already exists',
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to create info page')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create info page',
|
||||
)
|
||||
|
||||
return InfoPageResponse.model_validate(page)
|
||||
|
||||
|
||||
@router.put('/{page_id}', response_model=InfoPageResponse)
|
||||
async def update_page(
|
||||
page_id: int,
|
||||
request: InfoPageUpdateRequest,
|
||||
admin: User = Depends(require_permission('info_pages:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> InfoPageResponse:
|
||||
"""Update an existing info page."""
|
||||
existing = await get_info_page_by_id(db, page_id)
|
||||
if not existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Info page not found',
|
||||
)
|
||||
|
||||
try:
|
||||
update_data = request.model_dump(exclude_unset=True)
|
||||
|
||||
replaces_tab = update_data.get('replaces_tab')
|
||||
if replaces_tab is not None:
|
||||
await clear_replaces_tab(db, replaces_tab, exclude_page_id=page_id)
|
||||
|
||||
page = await update_info_page(db, page_id, **update_data)
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='An info page with this slug already exists',
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to update info page', page_id=page_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to update info page',
|
||||
)
|
||||
|
||||
if not page:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Info page not found after update',
|
||||
)
|
||||
return InfoPageResponse.model_validate(page)
|
||||
|
||||
|
||||
@router.delete('/{page_id}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_page(
|
||||
page_id: int,
|
||||
admin: User = Depends(require_permission('info_pages:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> None:
|
||||
"""Delete an info page."""
|
||||
existing = await get_info_page_by_id(db, page_id)
|
||||
if not existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Info page not found',
|
||||
)
|
||||
|
||||
try:
|
||||
await delete_info_page(db, page_id)
|
||||
except Exception:
|
||||
logger.exception('Failed to delete info page', page_id=page_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to delete info page',
|
||||
)
|
||||
|
||||
|
||||
@router.post('/reorder', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def reorder_pages(
|
||||
request: ReorderRequest,
|
||||
admin: User = Depends(require_permission('info_pages:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> None:
|
||||
"""Bulk update sort_order for info pages."""
|
||||
try:
|
||||
await reorder_info_pages(db, request.items)
|
||||
except Exception:
|
||||
logger.exception('Failed to reorder info pages')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to reorder info pages',
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{page_id}/toggle-active', response_model=InfoPageResponse)
|
||||
async def toggle_active(
|
||||
page_id: int,
|
||||
admin: User = Depends(require_permission('info_pages:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> InfoPageResponse:
|
||||
"""Toggle the active status of an info page."""
|
||||
existing = await get_info_page_by_id(db, page_id)
|
||||
if not existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Info page not found',
|
||||
)
|
||||
|
||||
try:
|
||||
page = await update_info_page(db, page_id, is_active=not existing.is_active)
|
||||
except Exception:
|
||||
logger.exception('Failed to toggle info page active status', page_id=page_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to toggle active status',
|
||||
)
|
||||
|
||||
if not page:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Info page not found after toggle',
|
||||
)
|
||||
return InfoPageResponse.model_validate(page)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,398 +0,0 @@
|
||||
"""Admin routes for cabinet menu layout configuration (rows + custom URL buttons).
|
||||
|
||||
Serves a MERGED view combining ``CABINET_MENU_LAYOUT`` (row arrangement, custom buttons)
|
||||
and ``CABINET_BUTTON_STYLES`` (per-section style/emoji/enabled/labels) to the frontend.
|
||||
On save, splits the payload back into two SystemSetting keys.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Literal
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.utils.button_styles_cache import (
|
||||
ALLOWED_STYLE_VALUES,
|
||||
BOT_LOCALES,
|
||||
BUTTON_STYLES_KEY,
|
||||
DEFAULT_BUTTON_STYLES,
|
||||
get_cached_button_styles,
|
||||
load_button_styles_cache,
|
||||
)
|
||||
from app.utils.menu_layout_cache import (
|
||||
BUILTIN_SECTIONS,
|
||||
DEFAULT_MENU_LAYOUT,
|
||||
MENU_LAYOUT_KEY,
|
||||
VALID_CUSTOM_BUTTON_STYLES,
|
||||
get_cached_menu_layout,
|
||||
load_menu_layout_cache,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/menu-layout', tags=['Admin Menu Layout'])
|
||||
|
||||
# ---- Constants ---------------------------------------------------------------
|
||||
|
||||
MAX_ROWS = 20
|
||||
MAX_BUTTONS_PER_ROW = 8 # Telegram inline keyboard limit
|
||||
MAX_LABEL_LENGTH = 100
|
||||
URL_PATTERN = re.compile(r'^(https?://|tg://)')
|
||||
|
||||
|
||||
# ---- Schemas -----------------------------------------------------------------
|
||||
|
||||
|
||||
class ButtonConfig(BaseModel):
|
||||
"""Configuration for a single button (built-in or custom URL)."""
|
||||
|
||||
id: str = Field(max_length=100)
|
||||
type: Literal['builtin', 'custom']
|
||||
style: str = Field(default='primary', max_length=20)
|
||||
icon_custom_emoji_id: str = Field(default='', max_length=100)
|
||||
enabled: bool = True
|
||||
labels: dict[str, str] = Field(default_factory=dict, max_length=10)
|
||||
url: str | None = Field(default=None, max_length=2048)
|
||||
open_in: Literal['external', 'webapp'] = 'external'
|
||||
|
||||
|
||||
class RowConfig(BaseModel):
|
||||
"""Configuration for a single row of buttons."""
|
||||
|
||||
id: str = Field(max_length=100)
|
||||
max_per_row: int = Field(default=2, ge=1, le=3)
|
||||
buttons: list[ButtonConfig] = Field(default_factory=list, max_length=MAX_BUTTONS_PER_ROW)
|
||||
|
||||
|
||||
class MenuConfigResponse(BaseModel):
|
||||
"""Full merged menu configuration returned to the frontend."""
|
||||
|
||||
rows: list[RowConfig]
|
||||
|
||||
|
||||
class MenuConfigUpdateRequest(BaseModel):
|
||||
"""Full menu configuration submitted by the frontend."""
|
||||
|
||||
rows: list[RowConfig] = Field(max_length=MAX_ROWS)
|
||||
|
||||
|
||||
# ---- Helpers -----------------------------------------------------------------
|
||||
|
||||
|
||||
async def _get_setting_value(db: AsyncSession, key: str) -> str | None:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.models import SystemSetting
|
||||
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
return setting.value if setting else None
|
||||
|
||||
|
||||
async def _upsert_setting(db: AsyncSession, key: str, value: str) -> None:
|
||||
"""Insert or update a SystemSetting without committing."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.models import SystemSetting
|
||||
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting:
|
||||
setting.value = value
|
||||
else:
|
||||
setting = SystemSetting(key=key, value=value)
|
||||
db.add(setting)
|
||||
|
||||
|
||||
def _build_merged_response(
|
||||
layout: dict[str, object],
|
||||
button_styles: dict[str, dict],
|
||||
) -> MenuConfigResponse:
|
||||
"""Merge layout rows with button_styles into a unified response.
|
||||
|
||||
Built-in buttons get style/emoji/enabled/labels from ``button_styles``.
|
||||
Custom URL buttons get all config from layout's ``custom_buttons``.
|
||||
"""
|
||||
custom_buttons: dict[str, dict] = layout.get('custom_buttons', {})
|
||||
|
||||
# Collect row entries sorted numerically (row_1, row_2, ..., row_10, ...)
|
||||
row_keys = sorted(
|
||||
(k for k in layout if k.startswith('row_')),
|
||||
key=lambda k: int(k.split('_', 1)[1]) if k.split('_', 1)[1].isdigit() else 0,
|
||||
)
|
||||
|
||||
rows: list[RowConfig] = []
|
||||
for row_key in row_keys:
|
||||
row_data = layout[row_key]
|
||||
if not isinstance(row_data, dict):
|
||||
continue
|
||||
|
||||
raw_buttons: list[str] = row_data.get('buttons', [])
|
||||
max_per_row: int = row_data.get('max_per_row', 2)
|
||||
row_id: str = row_data.get('id', row_key)
|
||||
|
||||
merged_buttons: list[ButtonConfig] = []
|
||||
for btn_id in raw_buttons:
|
||||
if btn_id in BUILTIN_SECTIONS:
|
||||
# Built-in: pull style data from button_styles cache
|
||||
style_cfg = button_styles.get(btn_id, {})
|
||||
merged_buttons.append(
|
||||
ButtonConfig(
|
||||
id=btn_id,
|
||||
type='builtin',
|
||||
style=style_cfg.get('style', 'primary'),
|
||||
icon_custom_emoji_id=style_cfg.get('icon_custom_emoji_id', ''),
|
||||
enabled=style_cfg.get('enabled', True),
|
||||
labels=style_cfg.get('labels', {}),
|
||||
),
|
||||
)
|
||||
elif btn_id.startswith('custom_') and btn_id in custom_buttons:
|
||||
# Custom URL button: pull config from layout's custom_buttons
|
||||
cb = custom_buttons[btn_id]
|
||||
merged_buttons.append(
|
||||
ButtonConfig(
|
||||
id=btn_id,
|
||||
type='custom',
|
||||
style=cb.get('style', 'primary'),
|
||||
icon_custom_emoji_id=cb.get('icon_custom_emoji_id', ''),
|
||||
enabled=cb.get('enabled', True),
|
||||
labels=cb.get('labels', {}),
|
||||
url=cb.get('url'),
|
||||
open_in=cb.get('open_in', 'external'),
|
||||
),
|
||||
)
|
||||
|
||||
rows.append(
|
||||
RowConfig(
|
||||
id=row_id,
|
||||
max_per_row=max_per_row,
|
||||
buttons=merged_buttons,
|
||||
),
|
||||
)
|
||||
|
||||
return MenuConfigResponse(rows=rows)
|
||||
|
||||
|
||||
def _split_update(
|
||||
rows: list[RowConfig],
|
||||
) -> tuple[dict[str, object], dict[str, dict]]:
|
||||
"""Split a flat list of RowConfig back into layout_data and button_styles_updates.
|
||||
|
||||
Returns:
|
||||
(layout_data, button_styles_updates)
|
||||
- layout_data: rows + custom_buttons for ``CABINET_MENU_LAYOUT``
|
||||
- button_styles_updates: ``{section: {style, icon_custom_emoji_id, enabled, labels}}``
|
||||
for built-in sections only
|
||||
"""
|
||||
layout_data: dict[str, object] = {}
|
||||
custom_buttons: dict[str, dict] = {}
|
||||
button_styles_updates: dict[str, dict] = {}
|
||||
|
||||
for idx, row in enumerate(rows, start=1):
|
||||
row_key = f'row_{idx}'
|
||||
button_ids: list[str] = []
|
||||
|
||||
for btn in row.buttons:
|
||||
button_ids.append(btn.id)
|
||||
|
||||
if btn.type == 'builtin' and btn.id in BUILTIN_SECTIONS:
|
||||
button_styles_updates[btn.id] = {
|
||||
'style': btn.style,
|
||||
'icon_custom_emoji_id': btn.icon_custom_emoji_id,
|
||||
'enabled': btn.enabled,
|
||||
'labels': btn.labels,
|
||||
}
|
||||
elif btn.type == 'custom' and btn.id.startswith('custom_'):
|
||||
custom_buttons[btn.id] = {
|
||||
'id': btn.id,
|
||||
'url': btn.url or '',
|
||||
'style': btn.style,
|
||||
'icon_custom_emoji_id': btn.icon_custom_emoji_id,
|
||||
'enabled': btn.enabled,
|
||||
'labels': btn.labels,
|
||||
'open_in': btn.open_in,
|
||||
}
|
||||
|
||||
layout_data[row_key] = {
|
||||
'id': row.id or row_key,
|
||||
'buttons': button_ids,
|
||||
'max_per_row': row.max_per_row,
|
||||
}
|
||||
|
||||
layout_data['custom_buttons'] = custom_buttons
|
||||
return layout_data, button_styles_updates
|
||||
|
||||
|
||||
def _validate_update_payload(rows: list[RowConfig]) -> None:
|
||||
"""Validate the full update payload. Raises HTTPException on failure."""
|
||||
if len(rows) > MAX_ROWS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Too many rows: {len(rows)}. Maximum allowed: {MAX_ROWS}.',
|
||||
)
|
||||
|
||||
# Check for duplicate button IDs across all rows
|
||||
seen_ids: set[str] = set()
|
||||
for row in rows:
|
||||
for btn in row.buttons:
|
||||
if btn.id in seen_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Duplicate button ID: "{btn.id}". Each button can only appear once.',
|
||||
)
|
||||
seen_ids.add(btn.id)
|
||||
|
||||
for row in rows:
|
||||
if len(row.buttons) > MAX_BUTTONS_PER_ROW:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Row "{row.id}" has {len(row.buttons)} buttons. Maximum per row: {MAX_BUTTONS_PER_ROW}.',
|
||||
)
|
||||
|
||||
for btn in row.buttons:
|
||||
# Validate button type consistency
|
||||
if btn.type == 'builtin' and btn.id not in BUILTIN_SECTIONS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Unknown built-in section: "{btn.id}".',
|
||||
)
|
||||
|
||||
if btn.type == 'custom' and not btn.id.startswith('custom_'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Custom button id must start with "custom_": "{btn.id}".',
|
||||
)
|
||||
|
||||
# Validate URL for custom buttons
|
||||
if btn.type == 'custom':
|
||||
if not btn.url or not URL_PATTERN.match(btn.url):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Custom button "{btn.id}" must have a URL starting with http://, https://, or tg://.',
|
||||
)
|
||||
if btn.open_in == 'webapp' and not btn.url.startswith('https://'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Custom button "{btn.id}" with webapp mode requires an https:// URL.',
|
||||
)
|
||||
|
||||
# Validate style
|
||||
all_allowed = ALLOWED_STYLE_VALUES | VALID_CUSTOM_BUTTON_STYLES
|
||||
if btn.style not in all_allowed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid style "{btn.style}" for button "{btn.id}". '
|
||||
f'Allowed: {", ".join(sorted(all_allowed))}.',
|
||||
)
|
||||
|
||||
# Validate labels
|
||||
for locale_key, label_val in btn.labels.items():
|
||||
if locale_key not in BOT_LOCALES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid locale "{locale_key}" for button "{btn.id}". '
|
||||
f'Allowed: {", ".join(BOT_LOCALES)}.',
|
||||
)
|
||||
if not isinstance(label_val, str):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Label value for locale "{locale_key}" must be a string.',
|
||||
)
|
||||
if len(label_val.strip()) > MAX_LABEL_LENGTH:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Label for locale "{locale_key}" on button "{btn.id}" '
|
||||
f'exceeds {MAX_LABEL_LENGTH} characters.',
|
||||
)
|
||||
|
||||
|
||||
# ---- Routes ------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get('', response_model=MenuConfigResponse)
|
||||
async def get_menu_layout(
|
||||
_admin: User = Depends(require_permission('settings:read')),
|
||||
):
|
||||
"""Return merged menu layout config (rows + button styles). Admin only."""
|
||||
layout = get_cached_menu_layout()
|
||||
button_styles = get_cached_button_styles()
|
||||
return _build_merged_response(layout, button_styles)
|
||||
|
||||
|
||||
@router.put('', response_model=MenuConfigResponse)
|
||||
async def update_menu_layout(
|
||||
payload: MenuConfigUpdateRequest,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Save full menu layout config. Splits into layout + button styles. Admin only."""
|
||||
_validate_update_payload(payload.rows)
|
||||
|
||||
layout_data, button_styles_updates = _split_update(payload.rows)
|
||||
|
||||
# Save layout to CABINET_MENU_LAYOUT (without committing)
|
||||
await _upsert_setting(db, MENU_LAYOUT_KEY, json.dumps(layout_data))
|
||||
|
||||
# Merge button styles updates with existing styles (don't overwrite sections not in request)
|
||||
if button_styles_updates:
|
||||
raw = await _get_setting_value(db, BUTTON_STYLES_KEY)
|
||||
current_styles: dict[str, dict] = {}
|
||||
if raw:
|
||||
try:
|
||||
current_styles = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
current_styles = {}
|
||||
|
||||
for section, updates in button_styles_updates.items():
|
||||
current_styles[section] = updates
|
||||
|
||||
await _upsert_setting(db, BUTTON_STYLES_KEY, json.dumps(current_styles))
|
||||
|
||||
# Single atomic commit for both settings
|
||||
await db.commit()
|
||||
|
||||
# Refresh caches after commit
|
||||
await load_button_styles_cache()
|
||||
await load_menu_layout_cache()
|
||||
|
||||
logger.info(
|
||||
'Admin updated menu layout',
|
||||
telegram_id=admin.telegram_id,
|
||||
rows_count=len(payload.rows),
|
||||
custom_buttons_count=len(layout_data.get('custom_buttons', {})),
|
||||
)
|
||||
|
||||
# Return merged response from fresh caches
|
||||
layout = get_cached_menu_layout()
|
||||
button_styles = get_cached_button_styles()
|
||||
return _build_merged_response(layout, button_styles)
|
||||
|
||||
|
||||
@router.post('/reset', response_model=MenuConfigResponse)
|
||||
async def reset_menu_layout(
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reset menu layout AND button styles to defaults. Admin only."""
|
||||
await _upsert_setting(db, MENU_LAYOUT_KEY, json.dumps(DEFAULT_MENU_LAYOUT))
|
||||
await _upsert_setting(db, BUTTON_STYLES_KEY, json.dumps(DEFAULT_BUTTON_STYLES))
|
||||
|
||||
# Single atomic commit for both settings
|
||||
await db.commit()
|
||||
|
||||
# Refresh caches after commit
|
||||
await load_button_styles_cache()
|
||||
await load_menu_layout_cache()
|
||||
|
||||
logger.info('Admin reset menu layout and button styles to defaults', telegram_id=admin.telegram_id)
|
||||
|
||||
layout = get_cached_menu_layout()
|
||||
button_styles = get_cached_button_styles()
|
||||
return _build_merged_response(layout, button_styles)
|
||||
@@ -1,343 +0,0 @@
|
||||
"""Admin routes for managing news articles in cabinet."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.news import (
|
||||
create_news_article,
|
||||
delete_news_article,
|
||||
get_all_news,
|
||||
get_all_news_count,
|
||||
get_news_article_by_id,
|
||||
unfeature_all_news,
|
||||
update_news_article,
|
||||
)
|
||||
from app.database.crud.news_categories import get_category_by_id
|
||||
from app.database.crud.news_tags import get_tag_by_id
|
||||
from app.database.models import NewsArticle, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.news import (
|
||||
NewsArticleListItem,
|
||||
NewsArticleResponse,
|
||||
NewsCreateRequest,
|
||||
NewsListResponse,
|
||||
NewsToggleResponse,
|
||||
NewsUpdateRequest,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/news', tags=['Cabinet Admin News'])
|
||||
|
||||
|
||||
def _article_to_detail(article: NewsArticle) -> dict[str, Any]:
|
||||
"""Convert NewsArticle ORM instance to full detail dict.
|
||||
|
||||
Expects the ``author`` relationship to be eagerly loaded.
|
||||
"""
|
||||
author_name: str | None = None
|
||||
if article.author:
|
||||
author_name = article.author.first_name or article.author.username or f'#{article.author.id}'
|
||||
|
||||
return {
|
||||
'id': article.id,
|
||||
'title': article.title,
|
||||
'slug': article.slug,
|
||||
'content': article.content,
|
||||
'excerpt': article.excerpt,
|
||||
'category': article.category,
|
||||
'category_color': article.category_color,
|
||||
'tag': article.tag,
|
||||
'category_id': article.category_id,
|
||||
'tag_id': article.tag_id,
|
||||
'featured_image_url': article.featured_image_url,
|
||||
'is_published': article.is_published,
|
||||
'is_featured': article.is_featured,
|
||||
'published_at': article.published_at,
|
||||
'read_time_minutes': article.read_time_minutes,
|
||||
'views_count': article.views_count,
|
||||
'author_name': author_name,
|
||||
'created_at': article.created_at,
|
||||
'updated_at': article.updated_at,
|
||||
}
|
||||
|
||||
|
||||
@router.get('', response_model=NewsListResponse)
|
||||
async def list_all_news(
|
||||
admin: User = Depends(require_permission('news:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> NewsListResponse:
|
||||
"""Get all news articles (admin view, includes unpublished)."""
|
||||
try:
|
||||
articles = await get_all_news(db, limit=limit, offset=offset)
|
||||
total = await get_all_news_count(db)
|
||||
|
||||
items = [NewsArticleListItem.model_validate(a) for a in articles]
|
||||
|
||||
return NewsListResponse(items=items, total=total)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception('Failed to list all news')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load news articles',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{article_id}', response_model=NewsArticleResponse)
|
||||
async def get_article_detail(
|
||||
article_id: int,
|
||||
admin: User = Depends(require_permission('news:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsArticleResponse:
|
||||
"""Get a single news article by ID (admin view)."""
|
||||
article = await get_news_article_by_id(db, article_id)
|
||||
if not article:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Article not found',
|
||||
)
|
||||
|
||||
return NewsArticleResponse(**_article_to_detail(article))
|
||||
|
||||
|
||||
@router.post('', response_model=NewsArticleResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_article(
|
||||
request: NewsCreateRequest,
|
||||
admin: User = Depends(require_permission('news:create')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsArticleResponse:
|
||||
"""Create a new news article."""
|
||||
try:
|
||||
# Resolve category from FK -- sync legacy string fields from the managed entity
|
||||
category_name = request.category
|
||||
category_color = request.category_color
|
||||
if request.category_id is not None:
|
||||
cat = await get_category_by_id(db, request.category_id)
|
||||
if not cat:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f'Category with id={request.category_id} not found',
|
||||
)
|
||||
category_name = cat.name
|
||||
category_color = cat.color
|
||||
|
||||
# Resolve tag from FK -- sync legacy string field from the managed entity
|
||||
tag_name = request.tag
|
||||
if request.tag_id is not None:
|
||||
tag_obj = await get_tag_by_id(db, request.tag_id)
|
||||
if not tag_obj:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f'Tag with id={request.tag_id} not found',
|
||||
)
|
||||
tag_name = tag_obj.name
|
||||
|
||||
if request.is_featured:
|
||||
await unfeature_all_news(db)
|
||||
article = await create_news_article(
|
||||
db,
|
||||
title=request.title,
|
||||
slug=request.slug,
|
||||
content=request.content,
|
||||
excerpt=request.excerpt,
|
||||
category=category_name,
|
||||
category_color=category_color,
|
||||
tag=tag_name,
|
||||
category_id=request.category_id,
|
||||
tag_id=request.tag_id,
|
||||
featured_image_url=request.featured_image_url,
|
||||
is_published=request.is_published,
|
||||
is_featured=request.is_featured,
|
||||
read_time_minutes=request.read_time_minutes,
|
||||
created_by=admin.id,
|
||||
)
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='An article with this slug already exists',
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to create news article')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create article',
|
||||
)
|
||||
|
||||
# Reload with author relationship
|
||||
article = await get_news_article_by_id(db, article.id)
|
||||
if not article:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to reload article after creation',
|
||||
)
|
||||
return NewsArticleResponse(**_article_to_detail(article))
|
||||
|
||||
|
||||
@router.put('/{article_id}', response_model=NewsArticleResponse)
|
||||
async def update_article(
|
||||
article_id: int,
|
||||
request: NewsUpdateRequest,
|
||||
admin: User = Depends(require_permission('news:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsArticleResponse:
|
||||
"""Update an existing news article."""
|
||||
article = await get_news_article_by_id(db, article_id)
|
||||
if not article:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Article not found',
|
||||
)
|
||||
|
||||
try:
|
||||
update_data = request.model_dump(exclude_unset=True)
|
||||
|
||||
# Resolve category from FK -- sync legacy string fields from the managed entity
|
||||
if 'category_id' in update_data and update_data['category_id'] is not None:
|
||||
cat = await get_category_by_id(db, update_data['category_id'])
|
||||
if not cat:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f'Category with id={update_data["category_id"]} not found',
|
||||
)
|
||||
update_data['category'] = cat.name
|
||||
update_data['category_color'] = cat.color
|
||||
|
||||
# Resolve tag from FK -- sync legacy string field from the managed entity
|
||||
if 'tag_id' in update_data and update_data['tag_id'] is not None:
|
||||
tag_obj = await get_tag_by_id(db, update_data['tag_id'])
|
||||
if not tag_obj:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f'Tag with id={update_data["tag_id"]} not found',
|
||||
)
|
||||
update_data['tag'] = tag_obj.name
|
||||
|
||||
if update_data.get('is_featured'):
|
||||
await unfeature_all_news(db)
|
||||
article = await update_news_article(db, article, **update_data)
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='An article with this slug already exists',
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to update news article', article_id=article_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to update article',
|
||||
)
|
||||
|
||||
# Reload with author relationship (update used bulk UPDATE, author not populated)
|
||||
article = await get_news_article_by_id(db, article.id)
|
||||
if not article:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to reload article after update',
|
||||
)
|
||||
return NewsArticleResponse(**_article_to_detail(article))
|
||||
|
||||
|
||||
@router.delete('/{article_id}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_article(
|
||||
article_id: int,
|
||||
admin: User = Depends(require_permission('news:delete')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> None:
|
||||
"""Delete a news article."""
|
||||
article = await get_news_article_by_id(db, article_id)
|
||||
if not article:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Article not found',
|
||||
)
|
||||
|
||||
try:
|
||||
await delete_news_article(db, article)
|
||||
except Exception:
|
||||
logger.exception('Failed to delete news article', article_id=article_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to delete article',
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{article_id}/publish', response_model=NewsToggleResponse)
|
||||
async def toggle_publish(
|
||||
article_id: int,
|
||||
admin: User = Depends(require_permission('news:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsToggleResponse:
|
||||
"""Toggle the published status of a news article."""
|
||||
article = await get_news_article_by_id(db, article_id)
|
||||
if not article:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Article not found',
|
||||
)
|
||||
|
||||
new_published = not article.is_published
|
||||
|
||||
update_kwargs: dict[str, Any] = {'is_published': new_published}
|
||||
# Auto-set published_at on first publish
|
||||
if new_published and article.published_at is None:
|
||||
update_kwargs['published_at'] = datetime.now(UTC)
|
||||
|
||||
try:
|
||||
article = await update_news_article(db, article, **update_kwargs)
|
||||
return NewsToggleResponse(
|
||||
id=article.id,
|
||||
is_published=article.is_published,
|
||||
is_featured=article.is_featured,
|
||||
published_at=article.published_at,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to toggle publish', article_id=article_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to toggle publish status',
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{article_id}/feature', response_model=NewsToggleResponse)
|
||||
async def toggle_featured(
|
||||
article_id: int,
|
||||
admin: User = Depends(require_permission('news:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsToggleResponse:
|
||||
"""Toggle the featured status of a news article."""
|
||||
article = await get_news_article_by_id(db, article_id)
|
||||
if not article:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Article not found',
|
||||
)
|
||||
|
||||
try:
|
||||
new_featured = not article.is_featured
|
||||
# Only one article can be featured at a time — unfeature all others first
|
||||
if new_featured:
|
||||
await unfeature_all_news(db)
|
||||
article = await update_news_article(db, article, is_featured=new_featured)
|
||||
return NewsToggleResponse(
|
||||
id=article.id,
|
||||
is_published=article.is_published,
|
||||
is_featured=article.is_featured,
|
||||
published_at=article.published_at,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to toggle featured', article_id=article_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to toggle featured status',
|
||||
)
|
||||
@@ -1,90 +0,0 @@
|
||||
"""Admin routes for managing news categories."""
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.news_categories import (
|
||||
create_category,
|
||||
delete_category,
|
||||
get_all_categories,
|
||||
get_category_by_id,
|
||||
update_category,
|
||||
)
|
||||
from app.database.models import User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.news_categories import NewsCategoryCreate, NewsCategoryResponse, NewsCategoryUpdate
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/news/categories', tags=['Cabinet Admin News Categories'])
|
||||
|
||||
|
||||
@router.get('', response_model=list[NewsCategoryResponse])
|
||||
async def list_categories(
|
||||
admin: User = Depends(require_permission('news:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> list[NewsCategoryResponse]:
|
||||
"""Get all news categories."""
|
||||
categories = await get_all_categories(db)
|
||||
return [NewsCategoryResponse.model_validate(c) for c in categories]
|
||||
|
||||
|
||||
@router.post('', response_model=NewsCategoryResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_new_category(
|
||||
request: NewsCategoryCreate,
|
||||
admin: User = Depends(require_permission('news:create')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsCategoryResponse:
|
||||
"""Create a new news category."""
|
||||
try:
|
||||
category = await create_category(db, name=request.name, color=request.color)
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='Category already exists',
|
||||
)
|
||||
return NewsCategoryResponse.model_validate(category)
|
||||
|
||||
|
||||
@router.put('/{category_id}', response_model=NewsCategoryResponse)
|
||||
async def update_existing_category(
|
||||
category_id: int,
|
||||
request: NewsCategoryUpdate,
|
||||
admin: User = Depends(require_permission('news:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsCategoryResponse:
|
||||
"""Update an existing news category."""
|
||||
category = await get_category_by_id(db, category_id)
|
||||
if not category:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Category not found',
|
||||
)
|
||||
try:
|
||||
category = await update_category(db, category, **request.model_dump(exclude_unset=True))
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='Category name already exists',
|
||||
)
|
||||
return NewsCategoryResponse.model_validate(category)
|
||||
|
||||
|
||||
@router.delete('/{category_id}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_category(
|
||||
category_id: int,
|
||||
admin: User = Depends(require_permission('news:delete')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> None:
|
||||
"""Delete a news category. Articles using it will have category_id set to NULL."""
|
||||
category = await get_category_by_id(db, category_id)
|
||||
if not category:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Category not found',
|
||||
)
|
||||
await delete_category(db, category)
|
||||
@@ -1,157 +0,0 @@
|
||||
"""Admin routes for managing news article media (images/videos)."""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
|
||||
from PIL import Image as PILImage
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.services.news_media_service import (
|
||||
SavedMedia,
|
||||
delete_media_file,
|
||||
detect_file_type,
|
||||
ensure_upload_dirs,
|
||||
save_image,
|
||||
save_video,
|
||||
)
|
||||
|
||||
from ..dependencies import require_permission
|
||||
from ..schemas.news_media import NewsMediaUploadResponse
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
_BYTES_PER_MB = 1024 * 1024
|
||||
|
||||
# Only allow UUID-hex filenames with expected extensions (path traversal defense-in-depth).
|
||||
# thumb_ prefix is NOT allowed — thumbnails are cleaned up automatically when the main file is deleted.
|
||||
_SAFE_FILENAME_RE = re.compile(r'^[0-9a-f]{32}\.(jpg|mp4|webm)$')
|
||||
|
||||
router = APIRouter(prefix='/admin/news/media', tags=['Cabinet Admin News Media'])
|
||||
|
||||
|
||||
_ALLOWED_SCHEMES = frozenset({'http', 'https'})
|
||||
|
||||
|
||||
def _build_media_url(request: Request, relative_path: str) -> str:
|
||||
"""Build a full URL for a media file, respecting reverse proxy headers."""
|
||||
proto = request.headers.get('X-Forwarded-Proto', request.url.scheme).split(',')[0].strip()
|
||||
if proto not in _ALLOWED_SCHEMES:
|
||||
proto = 'https'
|
||||
host = request.headers.get('X-Forwarded-Host', request.headers.get('Host', request.url.netloc))
|
||||
host = host.split(',')[0].strip()
|
||||
return f'{proto}://{host}/uploads/{relative_path}'
|
||||
|
||||
|
||||
def _build_response(request: Request, saved: SavedMedia) -> NewsMediaUploadResponse:
|
||||
"""Convert SavedMedia to API response with full URLs."""
|
||||
thumbnail_url = _build_media_url(request, saved.thumbnail_path) if saved.thumbnail_path else None
|
||||
|
||||
return NewsMediaUploadResponse(
|
||||
url=_build_media_url(request, saved.relative_path),
|
||||
thumbnail_url=thumbnail_url,
|
||||
media_type=saved.media_type,
|
||||
filename=saved.filename,
|
||||
size_bytes=saved.size_bytes,
|
||||
width=saved.width,
|
||||
height=saved.height,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/upload', response_model=NewsMediaUploadResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def upload_media(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
admin: User = Depends(require_permission('news:edit')),
|
||||
) -> NewsMediaUploadResponse:
|
||||
"""Upload an image or video for a news article."""
|
||||
# Read with a hard budget to prevent memory exhaustion from huge uploads.
|
||||
# Read slightly over the max allowed size so we can detect oversized files.
|
||||
absolute_max_bytes = settings.MEDIA_MAX_VIDEO_SIZE_MB * _BYTES_PER_MB + 1
|
||||
data = await file.read(absolute_max_bytes)
|
||||
await file.close()
|
||||
if not data:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Empty file',
|
||||
)
|
||||
|
||||
if len(data) >= absolute_max_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f'File too large. Absolute maximum: {settings.MEDIA_MAX_VIDEO_SIZE_MB} MB',
|
||||
)
|
||||
|
||||
# Detect type from magic bytes
|
||||
try:
|
||||
media_type, _ext = detect_file_type(data)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
||||
detail='Unsupported file type. Allowed: JPEG, PNG, WebP, MP4, WebM',
|
||||
) from None
|
||||
|
||||
# Enforce per-type size limits
|
||||
max_size_mb = settings.MEDIA_MAX_IMAGE_SIZE_MB if media_type == 'image' else settings.MEDIA_MAX_VIDEO_SIZE_MB
|
||||
if len(data) > max_size_mb * _BYTES_PER_MB:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f'File too large. Maximum size for {media_type}: {max_size_mb} MB',
|
||||
)
|
||||
|
||||
upload_path = settings.get_media_upload_path()
|
||||
await asyncio.to_thread(ensure_upload_dirs, upload_path)
|
||||
|
||||
try:
|
||||
if media_type == 'image':
|
||||
saved = await save_image(
|
||||
data,
|
||||
upload_path,
|
||||
max_dim=settings.MEDIA_IMAGE_MAX_DIMENSION,
|
||||
quality=settings.MEDIA_JPEG_QUALITY,
|
||||
)
|
||||
else:
|
||||
saved = await save_video(data, upload_path)
|
||||
except (ValueError, OSError, PILImage.DecompressionBombError) as exc:
|
||||
logger.warning('Failed to save uploaded media', media_type=media_type, error=str(exc))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail='Failed to process uploaded file',
|
||||
) from None
|
||||
|
||||
logger.info(
|
||||
'Media uploaded',
|
||||
filename=saved.filename,
|
||||
media_type=saved.media_type,
|
||||
size_bytes=saved.size_bytes,
|
||||
admin_id=admin.id,
|
||||
)
|
||||
|
||||
return _build_response(request, saved)
|
||||
|
||||
|
||||
@router.delete('/{filename}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_media(
|
||||
filename: str,
|
||||
admin: User = Depends(require_permission('news:delete')),
|
||||
) -> None:
|
||||
"""Delete a previously uploaded media file."""
|
||||
if not _SAFE_FILENAME_RE.match(filename):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid filename',
|
||||
)
|
||||
|
||||
upload_path = settings.get_media_upload_path()
|
||||
|
||||
deleted = await asyncio.to_thread(delete_media_file, filename, upload_path)
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='File not found',
|
||||
)
|
||||
|
||||
logger.info('Media deleted', filename=filename, admin_id=admin.id)
|
||||
@@ -1,90 +0,0 @@
|
||||
"""Admin routes for managing news tags."""
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.news_tags import (
|
||||
create_tag,
|
||||
delete_tag,
|
||||
get_all_tags,
|
||||
get_tag_by_id,
|
||||
update_tag,
|
||||
)
|
||||
from app.database.models import User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.news_tags import NewsTagCreate, NewsTagResponse, NewsTagUpdate
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/news/tags', tags=['Cabinet Admin News Tags'])
|
||||
|
||||
|
||||
@router.get('', response_model=list[NewsTagResponse])
|
||||
async def list_tags(
|
||||
admin: User = Depends(require_permission('news:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> list[NewsTagResponse]:
|
||||
"""Get all news tags."""
|
||||
tags = await get_all_tags(db)
|
||||
return [NewsTagResponse.model_validate(t) for t in tags]
|
||||
|
||||
|
||||
@router.post('', response_model=NewsTagResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_new_tag(
|
||||
request: NewsTagCreate,
|
||||
admin: User = Depends(require_permission('news:create')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsTagResponse:
|
||||
"""Create a new news tag."""
|
||||
try:
|
||||
tag = await create_tag(db, name=request.name, color=request.color)
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='Tag already exists',
|
||||
)
|
||||
return NewsTagResponse.model_validate(tag)
|
||||
|
||||
|
||||
@router.put('/{tag_id}', response_model=NewsTagResponse)
|
||||
async def update_existing_tag(
|
||||
tag_id: int,
|
||||
request: NewsTagUpdate,
|
||||
admin: User = Depends(require_permission('news:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsTagResponse:
|
||||
"""Update an existing news tag."""
|
||||
tag = await get_tag_by_id(db, tag_id)
|
||||
if not tag:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Tag not found',
|
||||
)
|
||||
try:
|
||||
tag = await update_tag(db, tag, **request.model_dump(exclude_unset=True))
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='Tag name already exists',
|
||||
)
|
||||
return NewsTagResponse.model_validate(tag)
|
||||
|
||||
|
||||
@router.delete('/{tag_id}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_tag(
|
||||
tag_id: int,
|
||||
admin: User = Depends(require_permission('news:delete')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> None:
|
||||
"""Delete a news tag. Articles using it will have tag_id set to NULL."""
|
||||
tag = await get_tag_by_id(db, tag_id)
|
||||
if not tag:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Tag not found',
|
||||
)
|
||||
await delete_tag(db, tag)
|
||||
@@ -1,606 +0,0 @@
|
||||
"""Admin routes for managing partners in cabinet."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import desc, func, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import (
|
||||
AdvertisingCampaign,
|
||||
PartnerApplication,
|
||||
PartnerStatus,
|
||||
ReferralEarning,
|
||||
User,
|
||||
)
|
||||
from app.services.partner_application_service import partner_application_service
|
||||
from app.services.partner_stats_service import PartnerStatsService
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.partners import (
|
||||
AdminApproveRequest,
|
||||
AdminPartnerApplicationItem,
|
||||
AdminPartnerApplicationsResponse,
|
||||
AdminPartnerDetailResponse,
|
||||
AdminPartnerItem,
|
||||
AdminPartnerListResponse,
|
||||
AdminRejectRequest,
|
||||
AdminUpdateCommissionRequest,
|
||||
CampaignSummary,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/partners', tags=['Cabinet Admin Partners'])
|
||||
|
||||
|
||||
# ==================== Settings ====================
|
||||
|
||||
|
||||
class PartnerSettingsResponse(BaseModel):
|
||||
withdrawal_enabled: bool
|
||||
withdrawal_min_amount_kopeks: int
|
||||
withdrawal_cooldown_days: int
|
||||
withdrawal_requisites_text: str
|
||||
partner_section_visible: bool
|
||||
referral_program_enabled: bool
|
||||
|
||||
|
||||
class PartnerSettingsUpdateRequest(BaseModel):
|
||||
withdrawal_enabled: bool | None = None
|
||||
withdrawal_min_amount_kopeks: int | None = Field(None, ge=0, le=100_000_000)
|
||||
withdrawal_cooldown_days: int | None = Field(None, ge=0, le=365)
|
||||
withdrawal_requisites_text: str | None = Field(None, max_length=2000)
|
||||
partner_section_visible: bool | None = None
|
||||
referral_program_enabled: bool | None = None
|
||||
|
||||
|
||||
def _build_partner_settings_response() -> PartnerSettingsResponse:
|
||||
return PartnerSettingsResponse(
|
||||
withdrawal_enabled=settings.REFERRAL_WITHDRAWAL_ENABLED,
|
||||
withdrawal_min_amount_kopeks=settings.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS,
|
||||
withdrawal_cooldown_days=settings.REFERRAL_WITHDRAWAL_COOLDOWN_DAYS,
|
||||
withdrawal_requisites_text=settings.REFERRAL_WITHDRAWAL_REQUISITES_TEXT,
|
||||
partner_section_visible=settings.REFERRAL_PARTNER_SECTION_VISIBLE,
|
||||
referral_program_enabled=settings.REFERRAL_PROGRAM_ENABLED,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/settings', response_model=PartnerSettingsResponse)
|
||||
async def get_partner_settings(
|
||||
admin: User = Depends(require_permission('partners:settings')),
|
||||
):
|
||||
"""Get partner system settings."""
|
||||
return _build_partner_settings_response()
|
||||
|
||||
|
||||
@router.patch('/settings', response_model=PartnerSettingsResponse)
|
||||
async def update_partner_settings(
|
||||
request: PartnerSettingsUpdateRequest,
|
||||
admin: User = Depends(require_permission('partners:settings')),
|
||||
):
|
||||
"""Update partner system settings."""
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
# Update in-memory settings
|
||||
if request.withdrawal_enabled is not None:
|
||||
settings.REFERRAL_WITHDRAWAL_ENABLED = request.withdrawal_enabled
|
||||
if request.withdrawal_min_amount_kopeks is not None:
|
||||
settings.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS = request.withdrawal_min_amount_kopeks
|
||||
if request.withdrawal_cooldown_days is not None:
|
||||
settings.REFERRAL_WITHDRAWAL_COOLDOWN_DAYS = request.withdrawal_cooldown_days
|
||||
if request.withdrawal_requisites_text is not None:
|
||||
settings.REFERRAL_WITHDRAWAL_REQUISITES_TEXT = request.withdrawal_requisites_text
|
||||
if request.partner_section_visible is not None:
|
||||
settings.REFERRAL_PARTNER_SECTION_VISIBLE = request.partner_section_visible
|
||||
if request.referral_program_enabled is not None:
|
||||
settings.REFERRAL_PROGRAM_ENABLED = request.referral_program_enabled
|
||||
|
||||
# Persist to .env file
|
||||
try:
|
||||
env_file = Path('.env')
|
||||
if await asyncio.to_thread(env_file.exists):
|
||||
lines = (await asyncio.to_thread(env_file.read_text)).splitlines()
|
||||
updates: dict[str, str] = {}
|
||||
|
||||
if request.withdrawal_enabled is not None:
|
||||
updates['REFERRAL_WITHDRAWAL_ENABLED'] = str(request.withdrawal_enabled).lower()
|
||||
if request.withdrawal_min_amount_kopeks is not None:
|
||||
updates['REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS'] = str(request.withdrawal_min_amount_kopeks)
|
||||
if request.withdrawal_cooldown_days is not None:
|
||||
updates['REFERRAL_WITHDRAWAL_COOLDOWN_DAYS'] = str(request.withdrawal_cooldown_days)
|
||||
if request.withdrawal_requisites_text is not None:
|
||||
# Sanitize: replace newlines to prevent .env injection
|
||||
sanitized = (
|
||||
request.withdrawal_requisites_text.replace('\r\n', ' ').replace('\n', ' ').replace('\r', ' ')
|
||||
)
|
||||
updates['REFERRAL_WITHDRAWAL_REQUISITES_TEXT'] = sanitized
|
||||
if request.partner_section_visible is not None:
|
||||
updates['REFERRAL_PARTNER_SECTION_VISIBLE'] = str(request.partner_section_visible).lower()
|
||||
if request.referral_program_enabled is not None:
|
||||
updates['REFERRAL_PROGRAM_ENABLED'] = str(request.referral_program_enabled).lower()
|
||||
|
||||
new_lines = []
|
||||
updated_keys: set[str] = 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)
|
||||
|
||||
for key, value in updates.items():
|
||||
if key not in updated_keys:
|
||||
new_lines.append(f'{key}={value}')
|
||||
|
||||
await asyncio.to_thread(env_file.write_text, '\n'.join(new_lines) + '\n')
|
||||
logger.info('Updated partner settings in .env file', admin_id=admin.id)
|
||||
except Exception as e:
|
||||
logger.warning('Failed to update .env file', error=e)
|
||||
|
||||
return _build_partner_settings_response()
|
||||
|
||||
|
||||
# ==================== Applications (static paths first) ====================
|
||||
|
||||
|
||||
@router.get('/applications', response_model=AdminPartnerApplicationsResponse)
|
||||
async def list_applications(
|
||||
application_status: Literal['pending', 'approved', 'rejected', 'none'] | None = Query(None, alias='status'),
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
admin: User = Depends(require_permission('partners:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""List partner applications."""
|
||||
applications, total = await partner_application_service.get_all_applications(
|
||||
db, status=application_status, limit=limit, offset=offset
|
||||
)
|
||||
|
||||
# Batch-fetch users to avoid N+1
|
||||
user_ids = list({app.user_id for app in applications})
|
||||
if user_ids:
|
||||
users_result = await db.execute(select(User).where(User.id.in_(user_ids)))
|
||||
users_map = {u.id: u for u in users_result.scalars().all()}
|
||||
else:
|
||||
users_map = {}
|
||||
|
||||
items = []
|
||||
for app in applications:
|
||||
user = users_map.get(app.user_id)
|
||||
items.append(
|
||||
AdminPartnerApplicationItem(
|
||||
id=app.id,
|
||||
user_id=app.user_id,
|
||||
username=user.username if user else None,
|
||||
first_name=user.first_name if user else None,
|
||||
telegram_id=user.telegram_id if user else None,
|
||||
company_name=app.company_name,
|
||||
website_url=app.website_url,
|
||||
telegram_channel=app.telegram_channel,
|
||||
description=app.description,
|
||||
expected_monthly_referrals=app.expected_monthly_referrals,
|
||||
desired_commission_percent=app.desired_commission_percent,
|
||||
status=app.status,
|
||||
admin_comment=app.admin_comment,
|
||||
approved_commission_percent=app.approved_commission_percent,
|
||||
created_at=app.created_at,
|
||||
processed_at=app.processed_at,
|
||||
)
|
||||
)
|
||||
|
||||
return AdminPartnerApplicationsResponse(items=items, total=total)
|
||||
|
||||
|
||||
@router.post('/applications/{application_id}/approve')
|
||||
async def approve_application(
|
||||
application_id: int,
|
||||
request: AdminApproveRequest,
|
||||
admin: User = Depends(require_permission('partners:approve')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Approve a partner application."""
|
||||
success, error = await partner_application_service.approve_application(
|
||||
db,
|
||||
application_id=application_id,
|
||||
admin_id=admin.id,
|
||||
commission_percent=request.commission_percent,
|
||||
comment=request.comment,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error,
|
||||
)
|
||||
|
||||
# Notify user about approval
|
||||
try:
|
||||
from app.bot_factory import create_bot
|
||||
from app.config import settings
|
||||
from app.services.notification_delivery_service import notification_delivery_service
|
||||
|
||||
if settings.BOT_TOKEN:
|
||||
application = await db.get(PartnerApplication, application_id)
|
||||
user = await db.get(User, application.user_id) if application else None
|
||||
if user:
|
||||
comment_text = f'\n{request.comment}' if request.comment else ''
|
||||
tg_message = (
|
||||
f'✅ Ваша заявка на партнёрство одобрена!\nКомиссия: {request.commission_percent}%{comment_text}'
|
||||
)
|
||||
bot = create_bot()
|
||||
try:
|
||||
await notification_delivery_service.notify_partner_approved(
|
||||
user=user,
|
||||
commission_percent=request.commission_percent,
|
||||
comment=request.comment,
|
||||
bot=bot,
|
||||
telegram_message=tg_message,
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error('Failed to send partner approval notification', error=e)
|
||||
|
||||
return {'success': True}
|
||||
|
||||
|
||||
@router.post('/applications/{application_id}/reject')
|
||||
async def reject_application(
|
||||
application_id: int,
|
||||
request: AdminRejectRequest,
|
||||
admin: User = Depends(require_permission('partners:approve')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reject a partner application."""
|
||||
success, error = await partner_application_service.reject_application(
|
||||
db,
|
||||
application_id=application_id,
|
||||
admin_id=admin.id,
|
||||
comment=request.comment,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error,
|
||||
)
|
||||
|
||||
# Notify user about rejection
|
||||
try:
|
||||
from app.bot_factory import create_bot
|
||||
from app.config import settings
|
||||
from app.services.notification_delivery_service import notification_delivery_service
|
||||
|
||||
if settings.BOT_TOKEN:
|
||||
application = await db.get(PartnerApplication, application_id)
|
||||
user = await db.get(User, application.user_id) if application else None
|
||||
if user:
|
||||
comment_text = f'\nПричина: {request.comment}' if request.comment else ''
|
||||
tg_message = f'❌ Ваша заявка на партнёрство отклонена.{comment_text}'
|
||||
bot = create_bot()
|
||||
try:
|
||||
await notification_delivery_service.notify_partner_rejected(
|
||||
user=user,
|
||||
comment=request.comment,
|
||||
bot=bot,
|
||||
telegram_message=tg_message,
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error('Failed to send partner rejection notification', error=e)
|
||||
|
||||
return {'success': True}
|
||||
|
||||
|
||||
# ==================== Stats (static paths) ====================
|
||||
|
||||
|
||||
@router.get('/stats')
|
||||
async def get_partner_stats(
|
||||
admin: User = Depends(require_permission('partners:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get overall partner statistics."""
|
||||
total_partners = await db.execute(
|
||||
select(func.count()).select_from(User).where(User.partner_status == PartnerStatus.APPROVED.value)
|
||||
)
|
||||
pending_apps = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(PartnerApplication)
|
||||
.where(PartnerApplication.status == PartnerStatus.PENDING.value)
|
||||
)
|
||||
total_referrals = await db.execute(select(func.count()).select_from(User).where(User.referred_by_id.isnot(None)))
|
||||
total_earnings = await db.execute(select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)))
|
||||
|
||||
return {
|
||||
'total_partners': total_partners.scalar() or 0,
|
||||
'pending_applications': pending_apps.scalar() or 0,
|
||||
'total_referrals': total_referrals.scalar() or 0,
|
||||
'total_earnings_kopeks': total_earnings.scalar() or 0,
|
||||
}
|
||||
|
||||
|
||||
# ==================== Partners list ====================
|
||||
|
||||
|
||||
@router.get('', response_model=AdminPartnerListResponse)
|
||||
async def list_partners(
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
admin: User = Depends(require_permission('partners:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""List approved partners."""
|
||||
count_result = await db.execute(
|
||||
select(func.count()).select_from(User).where(User.partner_status == PartnerStatus.APPROVED.value)
|
||||
)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.partner_status == PartnerStatus.APPROVED.value)
|
||||
.order_by(desc(User.created_at))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
partners = result.scalars().all()
|
||||
|
||||
# Batch-fetch earnings and referral counts to avoid N+1
|
||||
partner_ids = [u.id for u in partners]
|
||||
earnings_map: dict[int, int] = {}
|
||||
referral_count_map: dict[int, int] = {}
|
||||
|
||||
if partner_ids:
|
||||
earnings_result = await db.execute(
|
||||
select(ReferralEarning.user_id, func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
|
||||
.where(ReferralEarning.user_id.in_(partner_ids))
|
||||
.group_by(ReferralEarning.user_id)
|
||||
)
|
||||
earnings_map = {row[0]: int(row[1]) for row in earnings_result.all()}
|
||||
|
||||
referral_result = await db.execute(
|
||||
select(User.referred_by_id, func.count())
|
||||
.where(User.referred_by_id.in_(partner_ids))
|
||||
.group_by(User.referred_by_id)
|
||||
)
|
||||
referral_count_map = {row[0]: row[1] for row in referral_result.all()}
|
||||
|
||||
items = []
|
||||
for user in partners:
|
||||
items.append(
|
||||
AdminPartnerItem(
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
telegram_id=user.telegram_id,
|
||||
commission_percent=user.referral_commission_percent,
|
||||
total_referrals=referral_count_map.get(user.id, 0),
|
||||
total_earnings_kopeks=earnings_map.get(user.id, 0),
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
partner_status=user.partner_status,
|
||||
created_at=user.created_at,
|
||||
)
|
||||
)
|
||||
|
||||
return AdminPartnerListResponse(items=items, total=total)
|
||||
|
||||
|
||||
# ==================== Partner detail (parametric paths last) ====================
|
||||
|
||||
|
||||
@router.get('/{user_id}', response_model=AdminPartnerDetailResponse)
|
||||
async def get_partner_detail(
|
||||
user_id: int,
|
||||
admin: User = Depends(require_permission('partners:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get detailed partner info."""
|
||||
user = await db.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Пользователь не найден',
|
||||
)
|
||||
|
||||
stats = await PartnerStatsService.get_referrer_detailed_stats(db, user_id)
|
||||
|
||||
# Get assigned campaigns with per-campaign stats
|
||||
campaigns_result = await db.execute(
|
||||
select(AdvertisingCampaign).where(AdvertisingCampaign.partner_user_id == user_id)
|
||||
)
|
||||
campaigns = campaigns_result.scalars().all()
|
||||
|
||||
campaign_ids = [c.id for c in campaigns]
|
||||
per_campaign_stats = await PartnerStatsService.get_per_campaign_stats(db, user_id, campaign_ids)
|
||||
|
||||
campaign_list = [
|
||||
CampaignSummary(
|
||||
id=c.id,
|
||||
name=c.name,
|
||||
start_parameter=c.start_parameter,
|
||||
is_active=c.is_active,
|
||||
registrations_count=per_campaign_stats.get(c.id, {}).get('registrations_count', 0),
|
||||
referrals_count=per_campaign_stats.get(c.id, {}).get('referrals_count', 0),
|
||||
earnings_kopeks=per_campaign_stats.get(c.id, {}).get('earnings_kopeks', 0),
|
||||
)
|
||||
for c in campaigns
|
||||
]
|
||||
|
||||
summary = stats['summary']
|
||||
earnings = stats['earnings']
|
||||
|
||||
return AdminPartnerDetailResponse(
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
telegram_id=user.telegram_id,
|
||||
commission_percent=user.referral_commission_percent,
|
||||
partner_status=user.partner_status,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
total_referrals=summary['total_referrals'],
|
||||
paid_referrals=summary['paid_referrals'],
|
||||
active_referrals=summary['active_referrals'],
|
||||
earnings_all_time=earnings['all_time_kopeks'],
|
||||
earnings_today=earnings['today_kopeks'],
|
||||
earnings_week=earnings['week_kopeks'],
|
||||
earnings_month=earnings['month_kopeks'],
|
||||
conversion_to_paid=summary['conversion_to_paid_percent'],
|
||||
campaigns=campaign_list,
|
||||
created_at=user.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.patch('/{user_id}/commission')
|
||||
async def update_commission(
|
||||
user_id: int,
|
||||
request: AdminUpdateCommissionRequest,
|
||||
admin: User = Depends(require_permission('partners:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update partner commission percent."""
|
||||
user = await db.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Пользователь не найден',
|
||||
)
|
||||
|
||||
if user.partner_status != PartnerStatus.APPROVED.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Пользователь не является партнёром',
|
||||
)
|
||||
|
||||
old_commission = user.referral_commission_percent
|
||||
user.referral_commission_percent = request.commission_percent
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Комиссия партнёра обновлена',
|
||||
user_id=user_id,
|
||||
old_commission=old_commission,
|
||||
new_commission=request.commission_percent,
|
||||
admin_id=admin.id,
|
||||
)
|
||||
|
||||
return {'success': True, 'commission_percent': request.commission_percent}
|
||||
|
||||
|
||||
@router.post('/{user_id}/revoke')
|
||||
async def revoke_partner(
|
||||
user_id: int,
|
||||
admin: User = Depends(require_permission('partners:revoke')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Revoke partner status."""
|
||||
success, error = await partner_application_service.revoke_partner(db, user_id=user_id, admin_id=admin.id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error,
|
||||
)
|
||||
|
||||
return {'success': True}
|
||||
|
||||
|
||||
@router.post('/{user_id}/campaigns/{campaign_id}/assign')
|
||||
async def assign_campaign(
|
||||
user_id: int,
|
||||
campaign_id: int,
|
||||
admin: User = Depends(require_permission('partners:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Assign a campaign to a partner."""
|
||||
campaign = await db.get(AdvertisingCampaign, campaign_id)
|
||||
if not campaign:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Кампания не найдена',
|
||||
)
|
||||
|
||||
user = await db.get(User, user_id)
|
||||
if not user or user.partner_status != PartnerStatus.APPROVED.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Пользователь не является партнёром',
|
||||
)
|
||||
|
||||
# Atomic check-and-set to prevent race conditions
|
||||
result = await db.execute(
|
||||
update(AdvertisingCampaign)
|
||||
.where(
|
||||
AdvertisingCampaign.id == campaign_id,
|
||||
or_(
|
||||
AdvertisingCampaign.partner_user_id.is_(None),
|
||||
AdvertisingCampaign.partner_user_id == user_id,
|
||||
),
|
||||
)
|
||||
.values(partner_user_id=user_id, updated_at=datetime.now(UTC))
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Кампания уже привязана к другому партнёру',
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Кампания привязана к партнёру',
|
||||
campaign_id=campaign_id,
|
||||
partner_user_id=user_id,
|
||||
admin_id=admin.id,
|
||||
)
|
||||
return {'success': True}
|
||||
|
||||
|
||||
@router.post('/{user_id}/campaigns/{campaign_id}/unassign')
|
||||
async def unassign_campaign(
|
||||
user_id: int,
|
||||
campaign_id: int,
|
||||
admin: User = Depends(require_permission('partners:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Unassign a campaign from a partner."""
|
||||
# Atomic check-and-unset to prevent race conditions
|
||||
result = await db.execute(
|
||||
update(AdvertisingCampaign)
|
||||
.where(
|
||||
AdvertisingCampaign.id == campaign_id,
|
||||
AdvertisingCampaign.partner_user_id == user_id,
|
||||
)
|
||||
.values(partner_user_id=None, updated_at=datetime.now(UTC))
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
campaign = await db.get(AdvertisingCampaign, campaign_id)
|
||||
if not campaign:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Кампания не найдена',
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Кампания не привязана к этому партнёру',
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Кампания откреплена от партнёра',
|
||||
campaign_id=campaign_id,
|
||||
partner_user_id=user_id,
|
||||
admin_id=admin.id,
|
||||
)
|
||||
return {'success': True}
|
||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
@@ -17,7 +17,7 @@ from app.services.payment_method_config_service import (
|
||||
update_sort_order,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -60,23 +60,10 @@ class PaymentMethodConfigResponse(BaseModel):
|
||||
class PaymentMethodConfigUpdateRequest(BaseModel):
|
||||
is_enabled: bool | None = None
|
||||
display_name: str | None = Field(default=None, description='Null to reset to default')
|
||||
sub_options: dict[str, bool] | None = None
|
||||
sub_options: dict | None = None
|
||||
min_amount_kopeks: int | None = Field(default=None, ge=0)
|
||||
max_amount_kopeks: int | None = Field(default=None, ge=0)
|
||||
user_type_filter: str | None = Field(default=None, pattern='^(all|telegram|email)$')
|
||||
|
||||
@field_validator('sub_options', mode='before')
|
||||
@classmethod
|
||||
def validate_sub_options(cls, v: dict[str, bool] | None) -> dict[str, bool] | None:
|
||||
if not v:
|
||||
return None
|
||||
if len(v) > 20:
|
||||
raise ValueError('sub_options cannot have more than 20 keys')
|
||||
for key in v:
|
||||
if not isinstance(key, str) or len(key) > 50:
|
||||
raise ValueError('sub_options keys must be strings of at most 50 characters')
|
||||
return v
|
||||
|
||||
first_topup_filter: str | None = Field(default=None, pattern='^(any|yes|no)$')
|
||||
promo_group_filter_mode: str | None = Field(default=None, pattern='^(all|selected)$')
|
||||
allowed_promo_group_ids: list[int] | None = None
|
||||
@@ -137,7 +124,7 @@ def _enrich_config(config, defaults: dict) -> PaymentMethodConfigResponse:
|
||||
|
||||
@router.get('', response_model=list[PaymentMethodConfigResponse])
|
||||
async def list_payment_methods(
|
||||
admin: User = Depends(require_permission('payment_methods:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""List all payment method configurations."""
|
||||
@@ -148,7 +135,7 @@ async def list_payment_methods(
|
||||
|
||||
@router.get('/promo-groups', response_model=list[PromoGroupSimple])
|
||||
async def list_promo_groups(
|
||||
admin: User = Depends(require_permission('payment_methods:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""List all promo groups for filter selector."""
|
||||
@@ -159,7 +146,7 @@ async def list_promo_groups(
|
||||
@router.get('/{method_id}', response_model=PaymentMethodConfigResponse)
|
||||
async def get_payment_method(
|
||||
method_id: str,
|
||||
admin: User = Depends(require_permission('payment_methods:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get a single payment method configuration."""
|
||||
@@ -176,7 +163,7 @@ async def get_payment_method(
|
||||
@router.put('/order')
|
||||
async def update_payment_methods_order(
|
||||
request: SortOrderRequest,
|
||||
admin: User = Depends(require_permission('payment_methods:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Batch update sort order for payment methods."""
|
||||
@@ -189,7 +176,7 @@ async def update_payment_methods_order(
|
||||
async def update_payment_method(
|
||||
method_id: str,
|
||||
request: PaymentMethodConfigUpdateRequest,
|
||||
admin: User = Depends(require_permission('payment_methods:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update a payment method configuration."""
|
||||
|
||||
@@ -1,23 +1,14 @@
|
||||
"""Admin routes for payment verification in cabinet."""
|
||||
|
||||
import math
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.database.models import PaymentMethod, User
|
||||
from app.services.payment_search_service import (
|
||||
MAX_ALL_TIME_DAYS,
|
||||
PeriodPreset,
|
||||
SearchParams,
|
||||
StatusFilter,
|
||||
search_payments,
|
||||
search_payments_stats,
|
||||
)
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.payment_verification_service import (
|
||||
SUPPORTED_MANUAL_CHECK_METHODS,
|
||||
@@ -28,7 +19,7 @@ from app.services.payment_verification_service import (
|
||||
run_manual_check,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -59,7 +50,6 @@ class PendingPaymentResponse(BaseModel):
|
||||
user_id: int | None = None
|
||||
user_telegram_id: int | None = None
|
||||
user_username: str | None = None
|
||||
user_email: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -93,16 +83,6 @@ class PaymentsStatsResponse(BaseModel):
|
||||
by_method: dict
|
||||
|
||||
|
||||
class SearchStatsResponse(BaseModel):
|
||||
"""Statistics for payment search results."""
|
||||
|
||||
total: int
|
||||
pending: int
|
||||
paid: int
|
||||
cancelled: int
|
||||
by_method: dict
|
||||
|
||||
|
||||
# ============ Helper functions ============
|
||||
|
||||
|
||||
@@ -226,7 +206,7 @@ def _is_checkable(record: PendingPayment) -> bool:
|
||||
if record.method == PaymentMethod.YOOKASSA:
|
||||
return status_str in {'pending', 'waiting_for_capture'}
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
return status_str == 'active'
|
||||
return status_str in {'active'}
|
||||
if record.method == PaymentMethod.CLOUDPAYMENTS:
|
||||
return status_str in {'pending', 'authorized'}
|
||||
if record.method == PaymentMethod.FREEKASSA:
|
||||
@@ -257,8 +237,6 @@ def _get_payment_url(record: PendingPayment) -> str | None:
|
||||
elif record.method == PaymentMethod.CLOUDPAYMENTS or record.method == PaymentMethod.FREEKASSA:
|
||||
payment_url = getattr(payment, 'payment_url', None) or payment_url
|
||||
|
||||
if payment_url and not payment_url.startswith(('https://', 'http://')):
|
||||
return None
|
||||
return payment_url
|
||||
|
||||
|
||||
@@ -283,7 +261,6 @@ def _record_to_response(record: PendingPayment) -> PendingPaymentResponse:
|
||||
user_id=record.user.id if record.user else None,
|
||||
user_telegram_id=record.user.telegram_id if record.user else None,
|
||||
user_username=record.user.username if record.user else None,
|
||||
user_email=record.user.email if record.user else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -295,7 +272,7 @@ async def get_all_pending_payments(
|
||||
page: int = Query(1, ge=1, description='Page number'),
|
||||
per_page: int = Query(20, ge=1, le=100, description='Items per page'),
|
||||
method_filter: str | None = Query(None, description='Filter by payment method'),
|
||||
admin: User = Depends(require_permission('payments:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get all pending payments for admin verification."""
|
||||
@@ -329,7 +306,7 @@ async def get_all_pending_payments(
|
||||
|
||||
@router.get('/stats', response_model=PaymentsStatsResponse)
|
||||
async def get_payments_stats(
|
||||
admin: User = Depends(require_permission('payments:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get statistics about pending payments."""
|
||||
@@ -348,145 +325,11 @@ async def get_payments_stats(
|
||||
)
|
||||
|
||||
|
||||
@router.get('/search', response_model=PendingPaymentListResponse)
|
||||
async def search_payments_endpoint(
|
||||
search: str | None = Query(
|
||||
None, max_length=256, description='Search query (invoice, @username, telegram_id, email)'
|
||||
),
|
||||
status_filter: str = Query('all', description='Status filter: all, pending, paid, cancelled'),
|
||||
method_filter: str | None = Query(None, description='Filter by payment method'),
|
||||
period: str = Query('24h', description='Period preset: 24h, 7d, 30d, all'),
|
||||
date_from: datetime | None = Query(None, description='Custom range start (ISO 8601)'),
|
||||
date_to: datetime | None = Query(None, description='Custom range end (ISO 8601)'),
|
||||
page: int = Query(1, ge=1, description='Page number'),
|
||||
per_page: int = Query(20, ge=1, le=100, description='Items per page'),
|
||||
admin: User = Depends(require_permission('payments:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Search payments across all providers with filters."""
|
||||
try:
|
||||
parsed_status = StatusFilter(status_filter)
|
||||
except ValueError:
|
||||
parsed_status = StatusFilter.ALL
|
||||
|
||||
try:
|
||||
parsed_period = PeriodPreset(period)
|
||||
except ValueError:
|
||||
parsed_period = PeriodPreset.H24
|
||||
|
||||
parsed_method: PaymentMethod | None = None
|
||||
if method_filter:
|
||||
try:
|
||||
parsed_method = PaymentMethod(method_filter)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Ensure custom dates are timezone-aware
|
||||
if date_from is not None and date_from.tzinfo is None:
|
||||
date_from = date_from.replace(tzinfo=UTC)
|
||||
if date_to is not None and date_to.tzinfo is None:
|
||||
date_to = date_to.replace(tzinfo=UTC)
|
||||
|
||||
# Clamp custom dates to safety limit
|
||||
min_allowed = datetime.now(UTC) - timedelta(days=MAX_ALL_TIME_DAYS)
|
||||
if date_from is not None and date_from < min_allowed:
|
||||
date_from = min_allowed
|
||||
if date_from is not None and date_to is not None and date_from > date_to:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='date_from must be before date_to')
|
||||
|
||||
params = SearchParams(
|
||||
search=search.strip() if search else None,
|
||||
status_filter=parsed_status,
|
||||
method_filter=parsed_method,
|
||||
period=parsed_period,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
)
|
||||
|
||||
page_items, total = await search_payments(db, params)
|
||||
pages = math.ceil(total / per_page) if total > 0 else 1
|
||||
items = [_record_to_response(p) for p in page_items]
|
||||
|
||||
return PendingPaymentListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/search/stats', response_model=SearchStatsResponse)
|
||||
async def search_payments_stats_endpoint(
|
||||
search: str | None = Query(
|
||||
None, max_length=256, description='Search query (invoice, @username, telegram_id, email)'
|
||||
),
|
||||
status_filter: str = Query('all', description='Status filter: all, pending, paid, cancelled'),
|
||||
method_filter: str | None = Query(None, description='Filter by payment method'),
|
||||
period: str = Query('24h', description='Period preset: 24h, 7d, 30d, all'),
|
||||
date_from: datetime | None = Query(None, description='Custom range start (ISO 8601)'),
|
||||
date_to: datetime | None = Query(None, description='Custom range end (ISO 8601)'),
|
||||
admin: User = Depends(require_permission('payments:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get aggregated statistics for payment search results."""
|
||||
try:
|
||||
parsed_status = StatusFilter(status_filter)
|
||||
except ValueError:
|
||||
parsed_status = StatusFilter.ALL
|
||||
|
||||
try:
|
||||
parsed_period = PeriodPreset(period)
|
||||
except ValueError:
|
||||
parsed_period = PeriodPreset.H24
|
||||
|
||||
parsed_method: PaymentMethod | None = None
|
||||
if method_filter:
|
||||
try:
|
||||
parsed_method = PaymentMethod(method_filter)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Ensure custom dates are timezone-aware
|
||||
if date_from is not None and date_from.tzinfo is None:
|
||||
date_from = date_from.replace(tzinfo=UTC)
|
||||
if date_to is not None and date_to.tzinfo is None:
|
||||
date_to = date_to.replace(tzinfo=UTC)
|
||||
|
||||
# Clamp custom dates to safety limit
|
||||
min_allowed = datetime.now(UTC) - timedelta(days=MAX_ALL_TIME_DAYS)
|
||||
if date_from is not None and date_from < min_allowed:
|
||||
date_from = min_allowed
|
||||
if date_from is not None and date_to is not None and date_from > date_to:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='date_from must be before date_to')
|
||||
|
||||
params = SearchParams(
|
||||
search=search.strip() if search else None,
|
||||
status_filter=parsed_status,
|
||||
method_filter=parsed_method,
|
||||
period=parsed_period,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
|
||||
stats = await search_payments_stats(db, params)
|
||||
|
||||
return SearchStatsResponse(
|
||||
total=stats.total,
|
||||
pending=stats.pending,
|
||||
paid=stats.paid,
|
||||
cancelled=stats.cancelled,
|
||||
by_method=stats.by_method or {},
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{method}/{payment_id}', response_model=PendingPaymentResponse)
|
||||
async def get_pending_payment_details(
|
||||
method: str,
|
||||
payment_id: int,
|
||||
admin: User = Depends(require_permission('payments:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get details of a specific pending payment."""
|
||||
@@ -495,7 +338,7 @@ async def get_pending_payment_details(
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid payment method',
|
||||
detail=f'Invalid payment method: {method}',
|
||||
)
|
||||
|
||||
record = await get_payment_record(db, payment_method, payment_id)
|
||||
@@ -513,7 +356,7 @@ async def get_pending_payment_details(
|
||||
async def check_payment_status(
|
||||
method: str,
|
||||
payment_id: int,
|
||||
admin: User = Depends(require_permission('payments:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Manually check and update payment status."""
|
||||
@@ -522,7 +365,7 @@ async def check_payment_status(
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid payment method',
|
||||
detail=f'Invalid payment method: {method}',
|
||||
)
|
||||
|
||||
# Get current record
|
||||
@@ -547,12 +390,8 @@ async def check_payment_status(
|
||||
old_is_paid = record.is_paid
|
||||
|
||||
# Run manual check
|
||||
bot = create_bot()
|
||||
try:
|
||||
payment_service = PaymentService(bot=bot)
|
||||
updated = await run_manual_check(db, payment_method, payment_id, payment_service)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
payment_service = PaymentService()
|
||||
updated = await run_manual_check(db, payment_method, payment_id, payment_service)
|
||||
|
||||
if not updated:
|
||||
return ManualCheckResponse(
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
"""Admin routes for pinned messages in cabinet."""
|
||||
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.config import settings
|
||||
from app.database.models import PinnedMessage, User
|
||||
from app.services.pinned_message_service import (
|
||||
broadcast_pinned_message,
|
||||
@@ -20,7 +22,7 @@ from app.services.pinned_message_service import (
|
||||
)
|
||||
from app.utils.validators import sanitize_html, validate_html_tags
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.pinned_messages import (
|
||||
PinnedMessageBroadcastResponse,
|
||||
PinnedMessageCreateRequest,
|
||||
@@ -75,7 +77,10 @@ _cached_bot: Bot | None = None
|
||||
def _get_bot() -> Bot:
|
||||
global _cached_bot
|
||||
if _cached_bot is None:
|
||||
_cached_bot = create_bot()
|
||||
_cached_bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
return _cached_bot
|
||||
|
||||
|
||||
@@ -84,7 +89,7 @@ def _get_bot() -> Bot:
|
||||
|
||||
@router.get('', response_model=PinnedMessageListResponse)
|
||||
async def list_pinned_messages(
|
||||
admin: User = Depends(require_permission('pinned_messages:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
@@ -112,7 +117,7 @@ async def list_pinned_messages(
|
||||
|
||||
@router.get('/active', response_model=PinnedMessageResponse | None)
|
||||
async def get_active_message(
|
||||
admin: User = Depends(require_permission('pinned_messages:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageResponse | None:
|
||||
"""Get current active pinned message."""
|
||||
@@ -125,7 +130,7 @@ async def get_active_message(
|
||||
@router.get('/{message_id}', response_model=PinnedMessageResponse)
|
||||
async def get_pinned_message(
|
||||
message_id: int,
|
||||
admin: User = Depends(require_permission('pinned_messages:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageResponse:
|
||||
"""Get pinned message by ID."""
|
||||
@@ -142,7 +147,7 @@ async def get_pinned_message(
|
||||
@router.post('', response_model=PinnedMessageBroadcastResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_pinned_message(
|
||||
payload: PinnedMessageCreateRequest,
|
||||
admin: User = Depends(require_permission('pinned_messages:create')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageBroadcastResponse:
|
||||
"""
|
||||
@@ -196,7 +201,7 @@ async def create_pinned_message(
|
||||
async def update_pinned_message(
|
||||
message_id: int,
|
||||
payload: PinnedMessageUpdateRequest,
|
||||
admin: User = Depends(require_permission('pinned_messages:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageResponse:
|
||||
"""Update a pinned message content, media, or settings."""
|
||||
@@ -222,7 +227,7 @@ async def update_pinned_message(
|
||||
if payload.send_on_every_start is not None:
|
||||
msg.send_on_every_start = payload.send_on_every_start
|
||||
|
||||
msg.updated_at = datetime.now(UTC)
|
||||
msg.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(msg)
|
||||
|
||||
@@ -235,7 +240,7 @@ async def update_pinned_message(
|
||||
async def update_pinned_message_settings(
|
||||
message_id: int,
|
||||
payload: PinnedMessageSettingsRequest,
|
||||
admin: User = Depends(require_permission('pinned_messages:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageResponse:
|
||||
"""Update only pinned message display settings."""
|
||||
@@ -250,7 +255,7 @@ async def update_pinned_message_settings(
|
||||
if payload.send_on_every_start is not None:
|
||||
msg.send_on_every_start = payload.send_on_every_start
|
||||
|
||||
msg.updated_at = datetime.now(UTC)
|
||||
msg.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(msg)
|
||||
|
||||
@@ -262,7 +267,7 @@ async def update_pinned_message_settings(
|
||||
|
||||
@router.post('/active/deactivate', response_model=PinnedMessageResponse | None)
|
||||
async def deactivate_active_message(
|
||||
admin: User = Depends(require_permission('pinned_messages:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageResponse | None:
|
||||
"""Deactivate the current active pinned message without unpinning from users."""
|
||||
@@ -277,7 +282,7 @@ async def deactivate_active_message(
|
||||
|
||||
@router.post('/active/unpin', response_model=PinnedMessageUnpinResponse)
|
||||
async def unpin_active_message(
|
||||
admin: User = Depends(require_permission('pinned_messages:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageUnpinResponse:
|
||||
"""Unpin messages from all users and deactivate the active pinned message."""
|
||||
@@ -306,7 +311,7 @@ async def unpin_active_message(
|
||||
async def activate_pinned_message(
|
||||
message_id: int,
|
||||
broadcast: bool = Query(False),
|
||||
admin: User = Depends(require_permission('pinned_messages:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageBroadcastResponse:
|
||||
"""
|
||||
@@ -327,11 +332,11 @@ async def activate_pinned_message(
|
||||
await db.execute(
|
||||
update(PinnedMessage)
|
||||
.where(PinnedMessage.is_active.is_(True))
|
||||
.values(is_active=False, updated_at=datetime.now(UTC))
|
||||
.values(is_active=False, updated_at=datetime.utcnow())
|
||||
)
|
||||
|
||||
msg.is_active = True
|
||||
msg.updated_at = datetime.now(UTC)
|
||||
msg.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(msg)
|
||||
|
||||
@@ -355,7 +360,7 @@ async def activate_pinned_message(
|
||||
@router.post('/{message_id}/broadcast', response_model=PinnedMessageBroadcastResponse)
|
||||
async def broadcast_message(
|
||||
message_id: int,
|
||||
admin: User = Depends(require_permission('pinned_messages:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageBroadcastResponse:
|
||||
"""Broadcast a pinned message to all active users."""
|
||||
@@ -386,7 +391,7 @@ async def broadcast_message(
|
||||
@router.delete('/{message_id}', status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
||||
async def delete_pinned_message(
|
||||
message_id: int,
|
||||
admin: User = Depends(require_permission('pinned_messages:delete')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> None:
|
||||
"""Delete a pinned message. Active messages must be deactivated first."""
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
"""Admin RBAC access policies management routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.rbac import AccessPolicyCRUD, AdminRoleCRUD
|
||||
from app.database.models import User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/rbac/policies', tags=['Admin RBAC Policies'])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
|
||||
class PolicyResponse(BaseModel):
|
||||
"""Access policy response."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
description: str | None = None
|
||||
role_id: int | None = None
|
||||
role_name: str | None = None
|
||||
priority: int
|
||||
effect: str
|
||||
conditions: dict[str, Any] = Field(default_factory=dict)
|
||||
resource: str
|
||||
actions: list[str] = Field(default_factory=list)
|
||||
is_active: bool
|
||||
created_by: int | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class PolicyCreateRequest(BaseModel):
|
||||
"""Create a new access policy."""
|
||||
|
||||
name: str = Field(min_length=1, max_length=200)
|
||||
description: str | None = None
|
||||
role_id: int | None = None
|
||||
priority: int = Field(default=0, ge=0, le=1000)
|
||||
effect: str = Field(pattern=r'^(allow|deny)$')
|
||||
conditions: dict[str, Any] = Field(default_factory=dict)
|
||||
resource: str = Field(min_length=1, max_length=100)
|
||||
actions: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PolicyUpdateRequest(BaseModel):
|
||||
"""Update policy fields (all optional)."""
|
||||
|
||||
name: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
description: str | None = None
|
||||
role_id: int | None = None
|
||||
priority: int | None = Field(default=None, ge=0, le=1000)
|
||||
effect: str | None = Field(default=None, pattern=r'^(allow|deny)$')
|
||||
conditions: dict[str, Any] | None = None
|
||||
resource: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
actions: list[str] | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
# ============ Helper Functions ============
|
||||
|
||||
|
||||
async def _policy_to_response(db: AsyncSession, policy) -> PolicyResponse:
|
||||
"""Convert AccessPolicy model to PolicyResponse with role name."""
|
||||
role_name = None
|
||||
if policy.role_id is not None:
|
||||
role = await AdminRoleCRUD.get_by_id(db, policy.role_id)
|
||||
if role:
|
||||
role_name = role.name
|
||||
|
||||
return PolicyResponse(
|
||||
id=policy.id,
|
||||
name=policy.name,
|
||||
description=policy.description,
|
||||
role_id=policy.role_id,
|
||||
role_name=role_name,
|
||||
priority=policy.priority,
|
||||
effect=policy.effect,
|
||||
conditions=policy.conditions or {},
|
||||
resource=policy.resource,
|
||||
actions=policy.actions or [],
|
||||
is_active=policy.is_active,
|
||||
created_by=policy.created_by,
|
||||
created_at=policy.created_at,
|
||||
)
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
|
||||
@router.get('', response_model=list[PolicyResponse])
|
||||
async def list_policies(
|
||||
admin: User = Depends(require_permission('roles:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
role_id: int | None = None,
|
||||
):
|
||||
"""List all access policies. Optionally filter by role_id."""
|
||||
policies = await AccessPolicyCRUD.get_all(db, role_id=role_id)
|
||||
return [await _policy_to_response(db, p) for p in policies]
|
||||
|
||||
|
||||
@router.post('', response_model=PolicyResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_policy(
|
||||
payload: PolicyCreateRequest,
|
||||
admin: User = Depends(require_permission('roles:create')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Create a new access policy (ABAC rule)."""
|
||||
# Validate role_id if provided
|
||||
if payload.role_id is not None:
|
||||
role = await AdminRoleCRUD.get_by_id(db, payload.role_id)
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Referenced role not found',
|
||||
)
|
||||
|
||||
policy = await AccessPolicyCRUD.create(
|
||||
db,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
role_id=payload.role_id,
|
||||
priority=payload.priority,
|
||||
effect=payload.effect,
|
||||
conditions=payload.conditions,
|
||||
resource=payload.resource,
|
||||
actions=payload.actions,
|
||||
created_by=admin.id,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Admin created access policy',
|
||||
admin_id=admin.id,
|
||||
policy_id=policy.id,
|
||||
policy_name=policy.name,
|
||||
effect=policy.effect,
|
||||
)
|
||||
return await _policy_to_response(db, policy)
|
||||
|
||||
|
||||
@router.put('/{policy_id}', response_model=PolicyResponse)
|
||||
async def update_policy(
|
||||
policy_id: int,
|
||||
payload: PolicyUpdateRequest,
|
||||
admin: User = Depends(require_permission('roles:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update an existing access policy."""
|
||||
existing = await AccessPolicyCRUD.get_by_id(db, policy_id)
|
||||
if not existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Policy not found',
|
||||
)
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
|
||||
# Validate role_id if changing
|
||||
if 'role_id' in update_data and update_data['role_id'] is not None:
|
||||
role = await AdminRoleCRUD.get_by_id(db, update_data['role_id'])
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Referenced role not found',
|
||||
)
|
||||
|
||||
updated = await AccessPolicyCRUD.update(db, policy_id, **update_data)
|
||||
if not updated:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Policy not found',
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Admin updated access policy',
|
||||
admin_id=admin.id,
|
||||
policy_id=policy_id,
|
||||
fields=list(update_data.keys()),
|
||||
)
|
||||
return await _policy_to_response(db, updated)
|
||||
|
||||
|
||||
@router.delete('/{policy_id}')
|
||||
async def delete_policy(
|
||||
policy_id: int,
|
||||
admin: User = Depends(require_permission('roles:delete')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Delete an access policy."""
|
||||
existing = await AccessPolicyCRUD.get_by_id(db, policy_id)
|
||||
if not existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Policy not found',
|
||||
)
|
||||
|
||||
deleted = await AccessPolicyCRUD.delete(db, policy_id)
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to delete policy',
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Admin deleted access policy',
|
||||
admin_id=admin.id,
|
||||
policy_id=policy_id,
|
||||
policy_name=existing.name,
|
||||
)
|
||||
return {'message': 'Policy deleted', 'policy_id': policy_id}
|
||||
@@ -4,17 +4,19 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Any, ClassVar
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.config import settings
|
||||
from app.database.crud.discount_offer import (
|
||||
count_discount_offers,
|
||||
list_discount_offers,
|
||||
@@ -32,7 +34,7 @@ from app.database.models import DiscountOffer, PromoOfferLog, PromoOfferTemplate
|
||||
from app.handlers.admin.messages import get_custom_users, get_target_users
|
||||
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -128,20 +130,6 @@ class PromoOfferBroadcastRequest(BaseModel):
|
||||
message_text: str | None = Field(None, description='Custom message text (HTML)')
|
||||
button_text: str | None = Field(None, description='Button text')
|
||||
|
||||
_TARGET_ALIASES: ClassVar[dict[str, str]] = {
|
||||
'no_sub': 'no',
|
||||
'all_users': 'all',
|
||||
'active_subscribers': 'active',
|
||||
'trial_users': 'trial',
|
||||
}
|
||||
|
||||
@validator('target')
|
||||
def normalize_target(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.strip().lower()
|
||||
return cls._TARGET_ALIASES.get(normalized, normalized)
|
||||
|
||||
|
||||
class PromoOfferBroadcastResponse(BaseModel):
|
||||
created_offers: int
|
||||
@@ -284,7 +272,7 @@ async def _resolve_target_users(db: AsyncSession, target: str) -> list[User]:
|
||||
|
||||
@router.get('/templates', response_model=PromoOfferTemplateListResponse)
|
||||
async def list_templates(
|
||||
admin: User = Depends(require_permission('promo_offers:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoOfferTemplateListResponse:
|
||||
"""Get list of promo offer templates."""
|
||||
@@ -300,7 +288,7 @@ async def list_templates(
|
||||
@router.get('/templates/{template_id}', response_model=PromoOfferTemplateResponse)
|
||||
async def get_template(
|
||||
template_id: int,
|
||||
admin: User = Depends(require_permission('promo_offers:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoOfferTemplateResponse:
|
||||
"""Get a promo offer template."""
|
||||
@@ -314,7 +302,7 @@ async def get_template(
|
||||
async def update_template(
|
||||
template_id: int,
|
||||
payload: PromoOfferTemplateUpdateRequest,
|
||||
admin: User = Depends(require_permission('promo_offers:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoOfferTemplateResponse:
|
||||
"""Update a promo offer template."""
|
||||
@@ -350,7 +338,7 @@ async def update_template(
|
||||
|
||||
@router.get('', response_model=PromoOfferListResponse)
|
||||
async def list_offers(
|
||||
admin: User = Depends(require_permission('promo_offers:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
@@ -381,7 +369,10 @@ async def list_offers(
|
||||
|
||||
def _get_bot() -> Bot:
|
||||
"""Create bot instance for sending notifications."""
|
||||
return create_bot()
|
||||
return Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
|
||||
|
||||
def _build_default_promo_message(
|
||||
@@ -500,7 +491,7 @@ async def _send_promo_notifications(
|
||||
@router.post('/broadcast', response_model=PromoOfferBroadcastResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def broadcast_offer(
|
||||
payload: PromoOfferBroadcastRequest,
|
||||
admin: User = Depends(require_permission('promo_offers:send')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoOfferBroadcastResponse:
|
||||
"""Broadcast promo offer to users with optional Telegram notification."""
|
||||
@@ -614,7 +605,7 @@ async def broadcast_offer(
|
||||
|
||||
@router.get('/logs', response_model=PromoOfferLogListResponse)
|
||||
async def get_logs(
|
||||
admin: User = Depends(require_permission('promo_offers:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
|
||||
@@ -28,10 +28,9 @@ from app.database.crud.promocode import (
|
||||
get_promocodes_list,
|
||||
update_promocode,
|
||||
)
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.database.models import PromoCode, PromoCodeType, PromoCodeUse, PromoGroup, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
router = APIRouter(prefix='/admin/promocodes', tags=['Admin Promocodes'])
|
||||
@@ -56,8 +55,6 @@ class PromoCodeResponse(BaseModel):
|
||||
valid_from: datetime
|
||||
valid_until: datetime | None = None
|
||||
promo_group_id: int | None = None
|
||||
tariff_id: int | None = None
|
||||
tariff_name: str | None = None
|
||||
created_by: int | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@@ -96,7 +93,6 @@ class PromoCodeCreateRequest(BaseModel):
|
||||
is_active: bool = True
|
||||
first_purchase_only: bool = False
|
||||
promo_group_id: int | None = None
|
||||
tariff_id: int | None = None
|
||||
|
||||
|
||||
class PromoCodeUpdateRequest(BaseModel):
|
||||
@@ -110,7 +106,6 @@ class PromoCodeUpdateRequest(BaseModel):
|
||||
is_active: bool | None = None
|
||||
first_purchase_only: bool | None = None
|
||||
promo_group_id: int | None = None
|
||||
tariff_id: int | None = None
|
||||
|
||||
|
||||
# ============== PromoGroup Schemas ==============
|
||||
@@ -167,18 +162,13 @@ def _normalize_datetime(value: datetime | None) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is not None and value.utcoffset() is not None:
|
||||
return value.astimezone(UTC)
|
||||
return value.astimezone(UTC).replace(tzinfo=None)
|
||||
if value.tzinfo is not None:
|
||||
return value
|
||||
return value.replace(tzinfo=None)
|
||||
return value
|
||||
|
||||
|
||||
async def _serialize_promocode(db: AsyncSession, promocode: PromoCode) -> PromoCodeResponse:
|
||||
tariff_name = None
|
||||
if promocode.tariff_id:
|
||||
tariff = await get_tariff_by_id(db, promocode.tariff_id)
|
||||
tariff_name = tariff.name if tariff else None
|
||||
|
||||
def _serialize_promocode(promocode: PromoCode) -> PromoCodeResponse:
|
||||
promo_type = PromoCodeType(promocode.type)
|
||||
return PromoCodeResponse(
|
||||
id=promocode.id,
|
||||
@@ -196,8 +186,6 @@ async def _serialize_promocode(db: AsyncSession, promocode: PromoCode) -> PromoC
|
||||
valid_from=promocode.valid_from,
|
||||
valid_until=promocode.valid_until,
|
||||
promo_group_id=promocode.promo_group_id,
|
||||
tariff_id=promocode.tariff_id,
|
||||
tariff_name=tariff_name,
|
||||
created_by=promocode.created_by,
|
||||
created_at=promocode.created_at,
|
||||
updated_at=promocode.updated_at,
|
||||
@@ -317,7 +305,7 @@ def _validate_update_payload(payload: PromoCodeUpdateRequest, promocode: PromoCo
|
||||
|
||||
@router.get('', response_model=PromoCodeListResponse)
|
||||
async def list_promocodes(
|
||||
admin: User = Depends(require_permission('promocodes:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
@@ -327,9 +315,8 @@ async def list_promocodes(
|
||||
total = await get_promocodes_count(db, is_active=is_active) or 0
|
||||
promocodes = await get_promocodes_list(db, offset=offset, limit=limit, is_active=is_active)
|
||||
|
||||
serialized = [await _serialize_promocode(db, p) for p in promocodes]
|
||||
return PromoCodeListResponse(
|
||||
items=serialized,
|
||||
items=[_serialize_promocode(promocode) for promocode in promocodes],
|
||||
total=int(total),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
@@ -339,7 +326,7 @@ async def list_promocodes(
|
||||
@router.get('/{promocode_id}', response_model=PromoCodeDetailResponse)
|
||||
async def get_promocode(
|
||||
promocode_id: int,
|
||||
admin: User = Depends(require_permission('promocodes:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoCodeDetailResponse:
|
||||
"""Get promocode details with usage statistics."""
|
||||
@@ -348,7 +335,7 @@ async def get_promocode(
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Promo code not found')
|
||||
|
||||
stats = await get_promocode_statistics(db, promocode_id)
|
||||
base = await _serialize_promocode(db, promocode)
|
||||
base = _serialize_promocode(promocode)
|
||||
recent_uses = [_serialize_recent_use(use) for use in stats.get('recent_uses', [])]
|
||||
|
||||
return PromoCodeDetailResponse(
|
||||
@@ -362,7 +349,7 @@ async def get_promocode(
|
||||
@router.post('', response_model=PromoCodeResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_promocode_endpoint(
|
||||
payload: PromoCodeCreateRequest,
|
||||
admin: User = Depends(require_permission('promocodes:create')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoCodeResponse:
|
||||
"""Create a new promocode."""
|
||||
@@ -401,20 +388,18 @@ async def create_promocode_endpoint(
|
||||
update_fields['first_purchase_only'] = payload.first_purchase_only
|
||||
if payload.promo_group_id is not None:
|
||||
update_fields['promo_group_id'] = payload.promo_group_id
|
||||
if payload.tariff_id is not None:
|
||||
update_fields['tariff_id'] = payload.tariff_id
|
||||
|
||||
if update_fields:
|
||||
promocode = await update_promocode(db, promocode, **update_fields)
|
||||
|
||||
return await _serialize_promocode(db, promocode)
|
||||
return _serialize_promocode(promocode)
|
||||
|
||||
|
||||
@router.patch('/{promocode_id}', response_model=PromoCodeResponse)
|
||||
async def update_promocode_endpoint(
|
||||
promocode_id: int,
|
||||
payload: PromoCodeUpdateRequest,
|
||||
admin: User = Depends(require_permission('promocodes:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoCodeResponse:
|
||||
"""Update an existing promocode."""
|
||||
@@ -461,14 +446,11 @@ async def update_promocode_endpoint(
|
||||
if payload.promo_group_id is not None:
|
||||
updates['promo_group_id'] = payload.promo_group_id
|
||||
|
||||
if payload.tariff_id is not None:
|
||||
updates['tariff_id'] = payload.tariff_id if payload.tariff_id != 0 else None
|
||||
|
||||
if not updates:
|
||||
return await _serialize_promocode(db, promocode)
|
||||
return _serialize_promocode(promocode)
|
||||
|
||||
promocode = await update_promocode(db, promocode, **updates)
|
||||
return await _serialize_promocode(db, promocode)
|
||||
return _serialize_promocode(promocode)
|
||||
|
||||
|
||||
@router.delete(
|
||||
@@ -478,7 +460,7 @@ async def update_promocode_endpoint(
|
||||
)
|
||||
async def delete_promocode_endpoint(
|
||||
promocode_id: int,
|
||||
admin: User = Depends(require_permission('promocodes:delete')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> Response:
|
||||
"""Delete a promocode."""
|
||||
@@ -504,66 +486,47 @@ class DeactivateDiscountResponse(BaseModel):
|
||||
@router.post('/deactivate-discount/{user_id}', response_model=DeactivateDiscountResponse)
|
||||
async def admin_deactivate_discount_promocode(
|
||||
user_id: int,
|
||||
admin: User = Depends(require_permission('promocodes:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> DeactivateDiscountResponse:
|
||||
"""Admin: deactivate a user's active discount (promo code or promo offer)."""
|
||||
"""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')
|
||||
|
||||
current_discount = getattr(target_user, 'promo_offer_discount_percent', 0) or 0
|
||||
source = getattr(target_user, 'promo_offer_discount_source', None)
|
||||
from app.services.promocode_service import PromoCodeService
|
||||
|
||||
if current_discount <= 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'User has no active discount')
|
||||
service = PromoCodeService()
|
||||
result = await service.deactivate_discount_promocode(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
admin_initiated=True,
|
||||
)
|
||||
|
||||
# If source is a promo code, use the service to properly rollback usage
|
||||
if source and source.startswith('promocode:'):
|
||||
from app.services.promocode_service import PromoCodeService
|
||||
|
||||
service = PromoCodeService()
|
||||
result = await service.deactivate_discount_promocode(
|
||||
db=db,
|
||||
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,
|
||||
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_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')
|
||||
|
||||
error_code = result.get('error', 'server_error')
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, error_messages.get(error_code, 'Failed to deactivate'))
|
||||
|
||||
# For non-promocode offers (admin offers, etc.) — just clear the fields
|
||||
old_percent = target_user.promo_offer_discount_percent
|
||||
target_user.promo_offer_discount_percent = 0
|
||||
target_user.promo_offer_discount_source = None
|
||||
target_user.promo_offer_discount_expires_at = None
|
||||
target_user.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
return DeactivateDiscountResponse(
|
||||
success=True,
|
||||
message=f'Promo offer deactivated for user {user_id}',
|
||||
deactivated_code=None,
|
||||
discount_percent=old_percent,
|
||||
user_id=user_id,
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error_message,
|
||||
)
|
||||
|
||||
|
||||
@@ -574,7 +537,7 @@ promo_groups_router = APIRouter(prefix='/admin/promo-groups', tags=['Admin Promo
|
||||
|
||||
@promo_groups_router.get('', response_model=PromoGroupListResponse)
|
||||
async def list_promo_groups(
|
||||
admin: User = Depends(require_permission('promo_groups:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
@@ -598,7 +561,7 @@ async def list_promo_groups(
|
||||
@promo_groups_router.get('/{group_id}', response_model=PromoGroupResponse)
|
||||
async def get_promo_group(
|
||||
group_id: int,
|
||||
admin: User = Depends(require_permission('promo_groups:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoGroupResponse:
|
||||
"""Get promo group details."""
|
||||
@@ -613,7 +576,7 @@ async def get_promo_group(
|
||||
@promo_groups_router.post('', response_model=PromoGroupResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_promo_group_endpoint(
|
||||
payload: PromoGroupCreateRequest,
|
||||
admin: User = Depends(require_permission('promo_groups:create')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoGroupResponse:
|
||||
"""Create a new promo group."""
|
||||
@@ -645,7 +608,7 @@ async def create_promo_group_endpoint(
|
||||
async def update_promo_group_endpoint(
|
||||
group_id: int,
|
||||
payload: PromoGroupUpdateRequest,
|
||||
admin: User = Depends(require_permission('promo_groups:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoGroupResponse:
|
||||
"""Update a promo group."""
|
||||
@@ -682,7 +645,7 @@ async def update_promo_group_endpoint(
|
||||
@promo_groups_router.delete('/{group_id}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_promo_group_endpoint(
|
||||
group_id: int,
|
||||
admin: User = Depends(require_permission('promo_groups:delete')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> Response:
|
||||
"""Delete a promo group."""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,10 @@
|
||||
"""Admin routes for RemnaWave management in cabinet."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.server_squad import (
|
||||
@@ -17,7 +16,7 @@ from app.database.crud.server_squad import (
|
||||
from app.database.models import User
|
||||
from app.utils.cache import cache
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.remnawave import (
|
||||
AutoSyncRunResponse,
|
||||
# Auto Sync
|
||||
@@ -110,10 +109,7 @@ def _parse_datetime(value: Any) -> datetime | None:
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value)
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=UTC)
|
||||
return parsed
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
@@ -130,22 +126,22 @@ def _serialize_node(node_data: dict[str, Any]) -> NodeInfo:
|
||||
is_disabled=bool(node_data.get('is_disabled')),
|
||||
is_node_online=bool(node_data.get('is_node_online')),
|
||||
is_xray_running=bool(node_data.get('is_xray_running')),
|
||||
users_online=node_data.get('users_online', 0),
|
||||
users_online=node_data.get('users_online'),
|
||||
traffic_used_bytes=node_data.get('traffic_used_bytes'),
|
||||
traffic_limit_bytes=node_data.get('traffic_limit_bytes'),
|
||||
last_status_change=_parse_datetime(node_data.get('last_status_change')),
|
||||
last_status_message=node_data.get('last_status_message'),
|
||||
xray_uptime=node_data.get('xray_uptime', 0) or 0,
|
||||
xray_uptime=node_data.get('xray_uptime'),
|
||||
is_traffic_tracking_active=bool(node_data.get('is_traffic_tracking_active', False)),
|
||||
traffic_reset_day=node_data.get('traffic_reset_day'),
|
||||
notify_percent=node_data.get('notify_percent'),
|
||||
consumption_multiplier=float(node_data.get('consumption_multiplier', 1.0)),
|
||||
cpu_count=node_data.get('cpu_count'),
|
||||
cpu_model=node_data.get('cpu_model'),
|
||||
total_ram=node_data.get('total_ram'),
|
||||
created_at=_parse_datetime(node_data.get('created_at')),
|
||||
updated_at=_parse_datetime(node_data.get('updated_at')),
|
||||
provider_uuid=node_data.get('provider_uuid'),
|
||||
versions=node_data.get('versions'),
|
||||
system=node_data.get('system'),
|
||||
active_plugin_uuid=node_data.get('active_plugin_uuid'),
|
||||
)
|
||||
|
||||
|
||||
@@ -154,7 +150,7 @@ def _serialize_node(node_data: dict[str, Any]) -> NodeInfo:
|
||||
|
||||
@router.get('/status', response_model=RemnaWaveStatusResponse)
|
||||
async def get_remnawave_status(
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> RemnaWaveStatusResponse:
|
||||
"""Get RemnaWave configuration and connection status."""
|
||||
service = _get_service()
|
||||
@@ -177,7 +173,7 @@ async def get_remnawave_status(
|
||||
|
||||
@router.get('/system', response_model=SystemStatsResponse)
|
||||
async def get_system_statistics(
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> SystemStatsResponse:
|
||||
"""Get full system statistics from RemnaWave."""
|
||||
service = _get_service()
|
||||
@@ -209,9 +205,11 @@ async def get_system_statistics(
|
||||
users_by_status=stats.get('users_by_status', {}),
|
||||
server_info=ServerInfo(
|
||||
cpu_cores=server_data.get('cpu_cores', 0),
|
||||
cpu_physical_cores=server_data.get('cpu_physical_cores', 0),
|
||||
memory_total=server_data.get('memory_total', 0),
|
||||
memory_used=server_data.get('memory_used', 0),
|
||||
memory_free=server_data.get('memory_free', 0),
|
||||
memory_available=server_data.get('memory_available', 0),
|
||||
uptime_seconds=server_data.get('uptime_seconds', 0),
|
||||
),
|
||||
bandwidth=Bandwidth(
|
||||
@@ -237,7 +235,7 @@ async def get_system_statistics(
|
||||
|
||||
@router.get('/nodes', response_model=NodesListResponse)
|
||||
async def list_nodes(
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> NodesListResponse:
|
||||
"""Get list of all nodes."""
|
||||
service = _get_service()
|
||||
@@ -251,7 +249,7 @@ async def list_nodes(
|
||||
|
||||
@router.get('/nodes/overview', response_model=NodesOverview)
|
||||
async def get_nodes_overview(
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> NodesOverview:
|
||||
"""Get nodes overview with statistics."""
|
||||
service = _get_service()
|
||||
@@ -277,7 +275,7 @@ async def get_nodes_overview(
|
||||
|
||||
@router.get('/nodes/realtime')
|
||||
async def get_nodes_realtime(
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get realtime node usage data."""
|
||||
service = _get_service()
|
||||
@@ -289,7 +287,7 @@ async def get_nodes_realtime(
|
||||
@router.get('/nodes/{node_uuid}', response_model=NodeInfo)
|
||||
async def get_node_details(
|
||||
node_uuid: str,
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> NodeInfo:
|
||||
"""Get detailed information about a specific node."""
|
||||
service = _get_service()
|
||||
@@ -308,7 +306,7 @@ async def get_node_details(
|
||||
@router.get('/nodes/{node_uuid}/statistics', response_model=NodeStatisticsResponse)
|
||||
async def get_node_statistics(
|
||||
node_uuid: str,
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> NodeStatisticsResponse:
|
||||
"""Get node statistics with usage history."""
|
||||
service = _get_service()
|
||||
@@ -334,13 +332,13 @@ async def get_node_usage(
|
||||
node_uuid: str,
|
||||
start: datetime | None = Query(default=None),
|
||||
end: datetime | None = Query(default=None),
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> NodeUsageResponse:
|
||||
"""Get node usage history for a date range."""
|
||||
service = _get_service()
|
||||
_ensure_configured(service)
|
||||
|
||||
end_dt = end or datetime.now(UTC)
|
||||
end_dt = end or datetime.utcnow()
|
||||
start_dt = start or (end_dt - timedelta(days=7))
|
||||
|
||||
if start_dt >= end_dt:
|
||||
@@ -357,7 +355,7 @@ async def get_node_usage(
|
||||
async def perform_node_action(
|
||||
node_uuid: str,
|
||||
payload: NodeActionRequest,
|
||||
admin: User = Depends(require_permission('remnawave:manage')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> NodeActionResponse:
|
||||
"""Perform an action on a node (enable/disable/restart)."""
|
||||
service = _get_service()
|
||||
@@ -396,21 +394,15 @@ async def perform_node_action(
|
||||
)
|
||||
|
||||
|
||||
class RestartAllNodesPayload(BaseModel):
|
||||
force_restart: bool = False
|
||||
|
||||
|
||||
@router.post('/nodes/restart-all', response_model=NodeActionResponse)
|
||||
async def restart_all_nodes(
|
||||
payload: RestartAllNodesPayload | None = None,
|
||||
admin: User = Depends(require_permission('remnawave:manage')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> NodeActionResponse:
|
||||
"""Restart all nodes."""
|
||||
service = _get_service()
|
||||
_ensure_configured(service)
|
||||
|
||||
force = payload.force_restart if payload else False
|
||||
success = await service.restart_all_nodes(force_restart=force)
|
||||
success = await service.restart_all_nodes()
|
||||
|
||||
if success:
|
||||
logger.info('Admin restarted all nodes', telegram_id=admin.telegram_id)
|
||||
@@ -426,7 +418,7 @@ async def restart_all_nodes(
|
||||
|
||||
@router.get('/squads', response_model=SquadsListResponse)
|
||||
async def list_squads(
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SquadsListResponse:
|
||||
"""Get list of all squads with local database info."""
|
||||
@@ -468,7 +460,7 @@ async def list_squads(
|
||||
@router.get('/squads/{squad_uuid}', response_model=SquadDetailResponse)
|
||||
async def get_squad_details(
|
||||
squad_uuid: str,
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SquadDetailResponse:
|
||||
"""Get detailed information about a squad."""
|
||||
@@ -511,7 +503,7 @@ async def get_squad_details(
|
||||
@router.post('/squads', response_model=SquadOperationResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_squad(
|
||||
payload: SquadCreateRequest,
|
||||
admin: User = Depends(require_permission('remnawave:manage')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> SquadOperationResponse:
|
||||
"""Create a new squad in RemnaWave."""
|
||||
service = _get_service()
|
||||
@@ -538,7 +530,7 @@ async def create_squad(
|
||||
async def update_squad(
|
||||
squad_uuid: str,
|
||||
payload: SquadUpdateRequest,
|
||||
admin: User = Depends(require_permission('remnawave:manage')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> SquadOperationResponse:
|
||||
"""Update a squad in RemnaWave."""
|
||||
service = _get_service()
|
||||
@@ -569,7 +561,7 @@ async def update_squad(
|
||||
async def perform_squad_action(
|
||||
squad_uuid: str,
|
||||
payload: SquadActionRequest,
|
||||
admin: User = Depends(require_permission('remnawave:manage')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> SquadOperationResponse:
|
||||
"""Perform an action on a squad."""
|
||||
service = _get_service()
|
||||
@@ -614,7 +606,7 @@ async def perform_squad_action(
|
||||
@router.delete('/squads/{squad_uuid}', response_model=SquadOperationResponse)
|
||||
async def delete_squad(
|
||||
squad_uuid: str,
|
||||
admin: User = Depends(require_permission('remnawave:manage')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> SquadOperationResponse:
|
||||
"""Delete a squad."""
|
||||
service = _get_service()
|
||||
@@ -637,7 +629,7 @@ async def delete_squad(
|
||||
@router.get('/squads/{squad_uuid}/migration-preview', response_model=MigrationPreviewResponse)
|
||||
async def preview_migration(
|
||||
squad_uuid: str,
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> MigrationPreviewResponse:
|
||||
"""Get migration preview for a squad."""
|
||||
@@ -662,7 +654,7 @@ async def preview_migration(
|
||||
@router.post('/squads/migrate', response_model=MigrationResponse)
|
||||
async def migrate_squad_users(
|
||||
payload: MigrationRequest,
|
||||
admin: User = Depends(require_permission('remnawave:manage')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> MigrationResponse:
|
||||
"""Migrate users from one squad to another."""
|
||||
@@ -736,7 +728,7 @@ async def migrate_squad_users(
|
||||
|
||||
@router.get('/inbounds', response_model=InboundsListResponse)
|
||||
async def list_inbounds(
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> InboundsListResponse:
|
||||
"""Get list of all available inbounds."""
|
||||
service = _get_service()
|
||||
@@ -751,7 +743,7 @@ async def list_inbounds(
|
||||
|
||||
@router.get('/sync/auto/status', response_model=AutoSyncStatus)
|
||||
async def get_auto_sync_status(
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> AutoSyncStatus:
|
||||
"""Get auto sync status."""
|
||||
if remnawave_sync_service is None:
|
||||
@@ -780,7 +772,7 @@ async def get_auto_sync_status(
|
||||
@router.post('/sync/auto/toggle', response_model=SyncResponse)
|
||||
async def toggle_auto_sync(
|
||||
payload: AutoSyncToggleRequest,
|
||||
admin: User = Depends(require_permission('remnawave:sync')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> SyncResponse:
|
||||
"""Toggle auto sync on/off."""
|
||||
if remnawave_sync_service is None:
|
||||
@@ -816,7 +808,7 @@ async def toggle_auto_sync(
|
||||
|
||||
@router.post('/sync/auto/run', response_model=AutoSyncRunResponse)
|
||||
async def run_auto_sync_now(
|
||||
admin: User = Depends(require_permission('remnawave:sync')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> AutoSyncRunResponse:
|
||||
"""Run auto sync immediately."""
|
||||
if remnawave_sync_service is None:
|
||||
@@ -844,7 +836,7 @@ async def run_auto_sync_now(
|
||||
@router.post('/sync/from-panel', response_model=SyncResponse)
|
||||
async def sync_from_panel(
|
||||
payload: SyncMode,
|
||||
admin: User = Depends(require_permission('remnawave:sync')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SyncResponse:
|
||||
"""Sync users from RemnaWave panel to bot."""
|
||||
@@ -868,7 +860,7 @@ async def sync_from_panel(
|
||||
|
||||
@router.post('/sync/to-panel', response_model=SyncResponse)
|
||||
async def sync_to_panel(
|
||||
admin: User = Depends(require_permission('remnawave:sync')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SyncResponse:
|
||||
"""Sync users from bot to RemnaWave panel."""
|
||||
@@ -887,7 +879,7 @@ async def sync_to_panel(
|
||||
|
||||
@router.post('/sync/servers', response_model=SyncResponse)
|
||||
async def sync_servers(
|
||||
admin: User = Depends(require_permission('remnawave:sync')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SyncResponse:
|
||||
"""Sync servers/squads from RemnaWave."""
|
||||
@@ -930,7 +922,7 @@ async def sync_servers(
|
||||
|
||||
@router.post('/sync/subscriptions/validate', response_model=SyncResponse)
|
||||
async def validate_subscriptions(
|
||||
admin: User = Depends(require_permission('remnawave:sync')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SyncResponse:
|
||||
"""Validate and fix subscriptions."""
|
||||
@@ -949,7 +941,7 @@ async def validate_subscriptions(
|
||||
|
||||
@router.post('/sync/subscriptions/cleanup', response_model=SyncResponse)
|
||||
async def cleanup_subscriptions(
|
||||
admin: User = Depends(require_permission('remnawave:sync')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SyncResponse:
|
||||
"""Cleanup orphaned subscriptions."""
|
||||
@@ -968,7 +960,7 @@ async def cleanup_subscriptions(
|
||||
|
||||
@router.post('/sync/subscriptions/statuses', response_model=SyncResponse)
|
||||
async def sync_subscription_statuses(
|
||||
admin: User = Depends(require_permission('remnawave:sync')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SyncResponse:
|
||||
"""Sync subscription statuses."""
|
||||
@@ -987,7 +979,7 @@ async def sync_subscription_statuses(
|
||||
|
||||
@router.get('/sync/recommendations', response_model=SyncResponse)
|
||||
async def get_sync_recommendations(
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SyncResponse:
|
||||
"""Get sync recommendations."""
|
||||
|
||||
@@ -1,558 +0,0 @@
|
||||
"""Admin RBAC roles management routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import sqlalchemy as sa
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.rbac import SUPERADMIN_LEVEL, AdminRoleCRUD, UserRoleCRUD
|
||||
from app.database.models import User
|
||||
from app.services.permission_service import PERMISSION_REGISTRY, get_all_permissions
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/rbac', tags=['Admin RBAC'])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
|
||||
class RoleResponse(BaseModel):
|
||||
"""Admin role with user count."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
description: str | None = None
|
||||
level: int
|
||||
permissions: list[str] = Field(default_factory=list)
|
||||
color: str | None = None
|
||||
icon: str | None = None
|
||||
is_system: bool
|
||||
is_active: bool
|
||||
user_count: int = 0
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class RoleCreateRequest(BaseModel):
|
||||
"""Create a new custom role."""
|
||||
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
description: str | None = None
|
||||
level: int = Field(ge=0, le=998)
|
||||
permissions: list[str] = Field(default_factory=list)
|
||||
color: str | None = Field(default=None, max_length=7)
|
||||
icon: str | None = Field(default=None, max_length=50)
|
||||
|
||||
|
||||
class RoleUpdateRequest(BaseModel):
|
||||
"""Update role fields (all optional)."""
|
||||
|
||||
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
description: str | None = None
|
||||
level: int | None = Field(default=None, ge=0, le=998)
|
||||
permissions: list[str] | None = None
|
||||
color: str | None = Field(default=None, max_length=7)
|
||||
icon: str | None = Field(default=None, max_length=50)
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class RoleAssignRequest(BaseModel):
|
||||
"""Assign a role to a user."""
|
||||
|
||||
user_id: int
|
||||
role_id: int
|
||||
expires_at: datetime | None = None
|
||||
|
||||
|
||||
class PermissionSection(BaseModel):
|
||||
"""Permission section with available actions."""
|
||||
|
||||
section: str
|
||||
actions: list[str]
|
||||
|
||||
|
||||
class UserRoleResponse(BaseModel):
|
||||
"""User-role assignment details."""
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
role_id: int
|
||||
role_name: str | None = None
|
||||
user_telegram_id: int | None = None
|
||||
user_username: str | None = None
|
||||
user_first_name: str | None = None
|
||||
user_email: str | None = None
|
||||
assigned_by: int | None = None
|
||||
assigned_at: datetime | None = None
|
||||
expires_at: datetime | None = None
|
||||
is_active: bool
|
||||
|
||||
|
||||
class AdminWithRolesResponse(BaseModel):
|
||||
"""User that has at least one admin role."""
|
||||
|
||||
user_id: int
|
||||
telegram_id: int | None = None
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
email: str | None = None
|
||||
role_names: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ============ Helper Functions ============
|
||||
|
||||
|
||||
async def _role_to_response(db: AsyncSession, role) -> RoleResponse:
|
||||
"""Convert AdminRole model to RoleResponse with user count."""
|
||||
user_count = await AdminRoleCRUD.count_users(db, role.id)
|
||||
return RoleResponse(
|
||||
id=role.id,
|
||||
name=role.name,
|
||||
description=role.description,
|
||||
level=role.level,
|
||||
permissions=role.permissions or [],
|
||||
color=role.color,
|
||||
icon=role.icon,
|
||||
is_system=role.is_system,
|
||||
is_active=role.is_active,
|
||||
user_count=user_count,
|
||||
created_at=role.created_at,
|
||||
)
|
||||
|
||||
|
||||
async def _get_admin_level(db: AsyncSession, admin: User) -> int:
|
||||
"""Get the effective management level of the current admin.
|
||||
|
||||
Superadmin-tier users (DB level 999 or legacy ADMIN_IDS) are promoted to
|
||||
level 1000 so they can manage peer Superadmins. Without this, the ``>=``
|
||||
hierarchy guard would block 999-vs-999 operations.
|
||||
"""
|
||||
from app.config import settings
|
||||
|
||||
_perms, _names, max_level = await UserRoleCRUD.get_user_permissions(db, admin.id)
|
||||
|
||||
# DB-assigned Superadmins can manage peers
|
||||
if max_level >= SUPERADMIN_LEVEL:
|
||||
max_level = SUPERADMIN_LEVEL + 1
|
||||
|
||||
# Legacy config-based admins always get the highest level
|
||||
if settings.is_admin(
|
||||
telegram_id=admin.telegram_id,
|
||||
email=admin.email if admin.email_verified else None,
|
||||
):
|
||||
max_level = max(max_level, SUPERADMIN_LEVEL + 1)
|
||||
|
||||
return max_level
|
||||
|
||||
|
||||
def _validate_permissions(permissions: list[str]) -> None:
|
||||
"""Validate that all provided permissions exist in the registry."""
|
||||
all_valid = set(get_all_permissions())
|
||||
# Also allow wildcard patterns
|
||||
all_valid.add('*:*')
|
||||
for section in PERMISSION_REGISTRY:
|
||||
all_valid.add(f'{section}:*')
|
||||
|
||||
invalid = [p for p in permissions if p not in all_valid]
|
||||
if invalid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid permissions: {", ".join(invalid)}',
|
||||
)
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
|
||||
@router.get('/permissions', response_model=list[PermissionSection])
|
||||
async def get_permission_registry(
|
||||
admin: User = Depends(require_permission('roles:read')),
|
||||
):
|
||||
"""Get all available permissions grouped by section."""
|
||||
return [
|
||||
PermissionSection(section=section, actions=list(actions)) for section, actions in PERMISSION_REGISTRY.items()
|
||||
]
|
||||
|
||||
|
||||
@router.get('/users', response_model=list[AdminWithRolesResponse])
|
||||
async def list_rbac_users(
|
||||
admin: User = Depends(require_permission('roles:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""List all users that have at least one active RBAC role."""
|
||||
from sqlalchemy import select as _sa_select
|
||||
from sqlalchemy.orm import selectinload as _sel
|
||||
|
||||
from app.database.models import UserRole as _UserRole
|
||||
|
||||
result = await db.execute(
|
||||
_sa_select(_UserRole)
|
||||
.options(_sel(_UserRole.user), _sel(_UserRole.role))
|
||||
.where(_UserRole.is_active.is_(True))
|
||||
.order_by(_UserRole.user_id)
|
||||
)
|
||||
assignments = result.scalars().all()
|
||||
|
||||
users_map: dict[int, AdminWithRolesResponse] = {}
|
||||
for a in assignments:
|
||||
if not a.user:
|
||||
continue
|
||||
if a.user_id not in users_map:
|
||||
users_map[a.user_id] = AdminWithRolesResponse(
|
||||
user_id=a.user_id,
|
||||
telegram_id=a.user.telegram_id,
|
||||
username=a.user.username,
|
||||
first_name=a.user.first_name,
|
||||
last_name=a.user.last_name,
|
||||
email=a.user.email,
|
||||
role_names=[],
|
||||
)
|
||||
if a.role:
|
||||
users_map[a.user_id].role_names.append(a.role.name)
|
||||
|
||||
return list(users_map.values())
|
||||
|
||||
|
||||
@router.get('/roles/{role_id}/users', response_model=list[UserRoleResponse])
|
||||
async def list_role_users(
|
||||
role_id: int,
|
||||
admin: User = Depends(require_permission('roles:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""List user-role assignments for a specific role."""
|
||||
from sqlalchemy.orm import selectinload as _sel
|
||||
|
||||
role = await AdminRoleCRUD.get_by_id(db, role_id)
|
||||
if not role:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Role not found')
|
||||
|
||||
from sqlalchemy import select as _sa_select
|
||||
|
||||
from app.database.models import UserRole as _UserRole
|
||||
|
||||
result = await db.execute(
|
||||
_sa_select(_UserRole)
|
||||
.options(_sel(_UserRole.user), _sel(_UserRole.role))
|
||||
.where(_UserRole.role_id == role_id, _UserRole.is_active.is_(True))
|
||||
.order_by(_UserRole.assigned_at.desc())
|
||||
)
|
||||
assignments = result.scalars().all()
|
||||
|
||||
return [
|
||||
UserRoleResponse(
|
||||
id=a.id,
|
||||
user_id=a.user_id,
|
||||
role_id=a.role_id,
|
||||
role_name=a.role.name if a.role else None,
|
||||
user_telegram_id=a.user.telegram_id if a.user else None,
|
||||
user_username=a.user.username if a.user else None,
|
||||
user_first_name=a.user.first_name if a.user else None,
|
||||
user_email=a.user.email if a.user else None,
|
||||
assigned_by=a.assigned_by,
|
||||
assigned_at=a.assigned_at,
|
||||
expires_at=a.expires_at,
|
||||
is_active=a.is_active,
|
||||
)
|
||||
for a in assignments
|
||||
]
|
||||
|
||||
|
||||
@router.get('/roles', response_model=list[RoleResponse])
|
||||
async def list_roles(
|
||||
admin: User = Depends(require_permission('roles:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
include_inactive: bool = False,
|
||||
):
|
||||
"""List all admin roles with user counts."""
|
||||
roles = await AdminRoleCRUD.get_all(db, include_inactive=include_inactive)
|
||||
return [await _role_to_response(db, role) for role in roles]
|
||||
|
||||
|
||||
@router.post('/roles', response_model=RoleResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_role(
|
||||
payload: RoleCreateRequest,
|
||||
admin: User = Depends(require_permission('roles:create')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Create a new custom admin role."""
|
||||
# Validate permissions list
|
||||
_validate_permissions(payload.permissions)
|
||||
|
||||
# Hierarchy enforcement: cannot create role with level >= own level
|
||||
admin_level = await _get_admin_level(db, admin)
|
||||
if payload.level >= admin_level:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Cannot create a role with level >= your own role level',
|
||||
)
|
||||
|
||||
# Check name uniqueness
|
||||
existing = await AdminRoleCRUD.get_by_name(db, payload.name)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='Role with this name already exists',
|
||||
)
|
||||
|
||||
role = await AdminRoleCRUD.create(
|
||||
db,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
level=payload.level,
|
||||
permissions=payload.permissions,
|
||||
color=payload.color,
|
||||
icon=payload.icon,
|
||||
created_by=admin.id,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info('Admin created role', admin_id=admin.id, role_id=role.id, role_name=role.name)
|
||||
return await _role_to_response(db, role)
|
||||
|
||||
|
||||
@router.put('/roles/{role_id}', response_model=RoleResponse)
|
||||
async def update_role(
|
||||
role_id: int,
|
||||
payload: RoleUpdateRequest,
|
||||
admin: User = Depends(require_permission('roles:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update an existing admin role."""
|
||||
role = await AdminRoleCRUD.get_by_id(db, role_id)
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Role not found',
|
||||
)
|
||||
|
||||
admin_level = await _get_admin_level(db, admin)
|
||||
|
||||
# Cannot edit a role at or above own level
|
||||
if role.level >= admin_level:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Cannot edit a role at or above your own level',
|
||||
)
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
|
||||
# System roles: only permissions can be extended, block is_active/level changes
|
||||
if role.is_system:
|
||||
blocked = {'is_active', 'level'} & update_data.keys()
|
||||
if blocked:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f'Cannot change {", ".join(sorted(blocked))} on a system role',
|
||||
)
|
||||
|
||||
# Validate level change
|
||||
if 'level' in update_data and update_data['level'] >= admin_level:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Cannot set role level >= your own role level',
|
||||
)
|
||||
|
||||
# Validate permissions
|
||||
if 'permissions' in update_data and update_data['permissions'] is not None:
|
||||
_validate_permissions(update_data['permissions'])
|
||||
|
||||
# Check name uniqueness if name is changing
|
||||
if 'name' in update_data and update_data['name'] != role.name:
|
||||
existing = await AdminRoleCRUD.get_by_name(db, update_data['name'])
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='Role with this name already exists',
|
||||
)
|
||||
|
||||
updated = await AdminRoleCRUD.update(db, role_id, **update_data)
|
||||
if not updated:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Role not found',
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info('Admin updated role', admin_id=admin.id, role_id=role_id, fields=list(update_data.keys()))
|
||||
return await _role_to_response(db, updated)
|
||||
|
||||
|
||||
@router.delete('/roles/{role_id}')
|
||||
async def delete_role(
|
||||
role_id: int,
|
||||
admin: User = Depends(require_permission('roles:delete')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Delete a custom admin role. System roles cannot be deleted."""
|
||||
role = await AdminRoleCRUD.get_by_id(db, role_id)
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Role not found',
|
||||
)
|
||||
|
||||
if role.is_system:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Cannot delete a system role',
|
||||
)
|
||||
|
||||
admin_level = await _get_admin_level(db, admin)
|
||||
if role.level >= admin_level:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Cannot delete a role at or above your own level',
|
||||
)
|
||||
|
||||
deleted = await AdminRoleCRUD.delete(db, role_id)
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to delete role',
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info('Admin deleted role', admin_id=admin.id, role_id=role_id, role_name=role.name)
|
||||
return {'message': 'Role deleted', 'role_id': role_id}
|
||||
|
||||
|
||||
@router.post('/assignments', response_model=UserRoleResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def assign_role(
|
||||
payload: RoleAssignRequest,
|
||||
admin: User = Depends(require_permission('roles:assign')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Assign a role to a user. Hierarchy enforcement applies."""
|
||||
role = await AdminRoleCRUD.get_by_id(db, payload.role_id)
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Role not found',
|
||||
)
|
||||
|
||||
# Superadmin role is managed exclusively via ADMIN_IDS/ADMIN_EMAILS env config
|
||||
if role.level >= SUPERADMIN_LEVEL:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Superadmin role is managed via ADMIN_IDS/ADMIN_EMAILS environment variables. '
|
||||
'Add the user there and restart the bot.',
|
||||
)
|
||||
|
||||
admin_level = await _get_admin_level(db, admin)
|
||||
|
||||
# Cannot assign a role with level >= own level
|
||||
if role.level >= admin_level:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Cannot assign a role with level >= your own role level',
|
||||
)
|
||||
|
||||
# Verify target user exists
|
||||
from app.database.crud.user import get_user_by_id
|
||||
|
||||
target_user = await get_user_by_id(db, payload.user_id)
|
||||
if not target_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Target user not found',
|
||||
)
|
||||
|
||||
user_role = await UserRoleCRUD.assign_role(
|
||||
db,
|
||||
user_id=payload.user_id,
|
||||
role_id=payload.role_id,
|
||||
assigned_by=admin.id,
|
||||
expires_at=payload.expires_at,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Admin assigned role',
|
||||
admin_id=admin.id,
|
||||
target_user_id=payload.user_id,
|
||||
role_id=payload.role_id,
|
||||
role_name=role.name,
|
||||
)
|
||||
return UserRoleResponse(
|
||||
id=user_role.id,
|
||||
user_id=user_role.user_id,
|
||||
role_id=user_role.role_id,
|
||||
role_name=role.name,
|
||||
user_telegram_id=target_user.telegram_id,
|
||||
user_username=target_user.username,
|
||||
user_first_name=target_user.first_name,
|
||||
user_email=target_user.email,
|
||||
assigned_by=user_role.assigned_by,
|
||||
assigned_at=user_role.assigned_at,
|
||||
expires_at=user_role.expires_at,
|
||||
is_active=user_role.is_active,
|
||||
)
|
||||
|
||||
|
||||
@router.delete('/assignments/{assignment_id}')
|
||||
async def revoke_role(
|
||||
assignment_id: int,
|
||||
admin: User = Depends(require_permission('roles:assign')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Revoke a role assignment. Superadmin roles are managed via env config."""
|
||||
from app.database.models import UserRole
|
||||
|
||||
# Lock the assignment row (FOR UPDATE held until commit)
|
||||
result = await db.execute(sa.select(UserRole).where(UserRole.id == assignment_id).with_for_update())
|
||||
user_role = result.scalar_one_or_none()
|
||||
if not user_role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Role assignment not found',
|
||||
)
|
||||
|
||||
role = await AdminRoleCRUD.get_by_id(db, user_role.role_id)
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Associated role not found',
|
||||
)
|
||||
|
||||
# Superadmin role is managed exclusively via env config
|
||||
if role.level >= SUPERADMIN_LEVEL:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Superadmin role is managed via ADMIN_IDS/ADMIN_EMAILS environment variables. '
|
||||
'Remove the user from env and restart the bot.',
|
||||
)
|
||||
|
||||
admin_level = await _get_admin_level(db, admin)
|
||||
|
||||
# Cannot revoke a role at or above own level
|
||||
if role.level >= admin_level:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Cannot revoke a role at or above your own level',
|
||||
)
|
||||
|
||||
# Revoke directly on the locked object (avoid CRUD re-fetch without FOR UPDATE)
|
||||
user_role.is_active = False
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Admin revoked role assignment',
|
||||
admin_id=admin.id,
|
||||
assignment_id=assignment_id,
|
||||
target_user_id=user_role.user_id,
|
||||
role_name=role.name,
|
||||
)
|
||||
|
||||
return {'message': 'Role revoked', 'assignment_id': assignment_id}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,7 @@ from app.database.crud.server_squad import (
|
||||
from app.database.models import PromoGroup, ServerSquad, Subscription, Tariff, User
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.servers import (
|
||||
PromoGroupInfo,
|
||||
ServerDetailResponse,
|
||||
@@ -66,7 +66,7 @@ async def _get_tariffs_using_server(db: AsyncSession, squad_uuid: str) -> list[s
|
||||
@router.get('', response_model=ServerListResponse)
|
||||
async def list_servers(
|
||||
include_unavailable: bool = True,
|
||||
admin: User = Depends(require_permission('servers:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of all servers."""
|
||||
@@ -103,7 +103,7 @@ async def list_servers(
|
||||
@router.get('/{server_id}', response_model=ServerDetailResponse)
|
||||
async def get_server(
|
||||
server_id: int,
|
||||
admin: User = Depends(require_permission('servers:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get detailed server info."""
|
||||
@@ -146,7 +146,7 @@ async def get_server(
|
||||
async def update_existing_server(
|
||||
server_id: int,
|
||||
request: ServerUpdateRequest,
|
||||
admin: User = Depends(require_permission('servers:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update an existing server."""
|
||||
@@ -191,7 +191,7 @@ async def update_existing_server(
|
||||
@router.post('/{server_id}/toggle', response_model=ServerToggleResponse)
|
||||
async def toggle_server(
|
||||
server_id: int,
|
||||
admin: User = Depends(require_permission('servers:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Toggle server availability."""
|
||||
@@ -218,7 +218,7 @@ async def toggle_server(
|
||||
@router.post('/{server_id}/trial', response_model=ServerTrialToggleResponse)
|
||||
async def toggle_server_trial(
|
||||
server_id: int,
|
||||
admin: User = Depends(require_permission('servers:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Toggle server trial eligibility."""
|
||||
@@ -245,7 +245,7 @@ async def toggle_server_trial(
|
||||
@router.get('/{server_id}/stats', response_model=ServerStatsResponse)
|
||||
async def get_server_stats(
|
||||
server_id: int,
|
||||
admin: User = Depends(require_permission('servers:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get server statistics."""
|
||||
@@ -287,7 +287,7 @@ async def get_server_stats(
|
||||
|
||||
@router.post('/sync', response_model=ServerSyncResponse)
|
||||
async def sync_servers(
|
||||
admin: User = Depends(require_permission('servers:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Sync servers with RemnaWave."""
|
||||
|
||||
@@ -13,7 +13,7 @@ from app.services.system_settings_service import (
|
||||
bot_configuration_service,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -179,7 +179,7 @@ def _serialize_definition(definition, include_choices: bool = True) -> SettingDe
|
||||
|
||||
@router.get('/categories', response_model=list[SettingCategorySummary])
|
||||
async def list_categories(
|
||||
admin: User = Depends(require_permission('settings:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get list of setting categories."""
|
||||
categories = bot_configuration_service.get_categories()
|
||||
@@ -196,7 +196,7 @@ async def list_categories(
|
||||
|
||||
@router.get('', response_model=list[SettingDefinition])
|
||||
async def list_settings(
|
||||
admin: User = Depends(require_permission('settings:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
category: str | None = Query(default=None, alias='category_key'),
|
||||
):
|
||||
"""Get list of all settings or settings for a specific category."""
|
||||
@@ -217,7 +217,7 @@ async def list_settings(
|
||||
@router.get('/{key}', response_model=SettingDefinition)
|
||||
async def get_setting(
|
||||
key: str,
|
||||
admin: User = Depends(require_permission('settings:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get a specific setting by key."""
|
||||
try:
|
||||
@@ -232,7 +232,7 @@ async def get_setting(
|
||||
async def update_setting(
|
||||
key: str,
|
||||
payload: SettingUpdateRequest,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update a setting value."""
|
||||
@@ -255,7 +255,7 @@ async def update_setting(
|
||||
@router.delete('/{key}', response_model=SettingDefinition)
|
||||
async def reset_setting(
|
||||
key: str,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reset a setting to its default value."""
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import sys
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
@@ -14,7 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database.crud.campaign import get_campaign_statistics, get_campaigns_count, get_campaigns_list
|
||||
from app.database.crud.server_squad import get_server_statistics
|
||||
from app.database.crud.subscription import get_subscriptions_statistics
|
||||
from app.database.crud.transaction import REAL_PAYMENT_METHODS, get_revenue_by_period, get_transactions_statistics
|
||||
from app.database.crud.transaction import get_revenue_by_period, get_transactions_statistics
|
||||
from app.database.models import (
|
||||
ReferralEarning,
|
||||
Subscription,
|
||||
@@ -27,7 +26,7 @@ from app.database.models import (
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.version_service import version_service
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -50,11 +49,15 @@ class NodeStatus(BaseModel):
|
||||
is_disabled: bool
|
||||
users_online: int
|
||||
traffic_used_bytes: int | None = None
|
||||
uptime: str | None = None
|
||||
xray_version: str | None = None
|
||||
node_version: str | None = None
|
||||
last_status_message: str | None = None
|
||||
xray_uptime: int = 0
|
||||
xray_uptime: str | None = None
|
||||
is_xray_running: bool | None = None
|
||||
versions: dict[str, str] | None = None
|
||||
system: dict[str, Any] | None = None
|
||||
cpu_count: int | None = None
|
||||
cpu_model: str | None = None
|
||||
total_ram: str | None = None
|
||||
country_code: str | None = None
|
||||
|
||||
|
||||
@@ -243,7 +246,7 @@ class RecentPaymentsResponse(BaseModel):
|
||||
|
||||
@router.get('/dashboard', response_model=DashboardStats)
|
||||
async def get_dashboard_stats(
|
||||
admin: User = Depends(require_permission('stats:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get complete dashboard statistics for admin panel."""
|
||||
@@ -255,13 +258,10 @@ async def get_dashboard_stats(
|
||||
sub_stats = await get_subscriptions_statistics(db)
|
||||
|
||||
# Get financial statistics
|
||||
now = datetime.now(UTC)
|
||||
now = datetime.utcnow()
|
||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
trans_stats = await get_transactions_statistics(db, month_start, now)
|
||||
all_time_stats = await get_transactions_statistics(
|
||||
db, start_date=datetime(2020, 1, 1, tzinfo=UTC), end_date=now
|
||||
)
|
||||
|
||||
# Get revenue chart data (last 30 days)
|
||||
revenue_data = await get_revenue_by_period(db, days=30)
|
||||
@@ -272,14 +272,6 @@ async def get_dashboard_stats(
|
||||
# Get tariff statistics
|
||||
tariff_stats = await _get_tariff_stats(db)
|
||||
|
||||
# Derive income_today from revenue_chart to ensure consistency with chart
|
||||
today_str = now.date().isoformat()
|
||||
income_today_from_chart = sum(
|
||||
item.get('amount_kopeks', 0) for item in revenue_data if str(item.get('date', '')) == today_str
|
||||
)
|
||||
# Use chart-derived value if available, otherwise fall back to trans_stats
|
||||
income_today_kopeks = income_today_from_chart or trans_stats.get('today', {}).get('income_kopeks', 0)
|
||||
|
||||
# Build response
|
||||
return DashboardStats(
|
||||
nodes=nodes_data,
|
||||
@@ -295,15 +287,14 @@ async def get_dashboard_stats(
|
||||
trial_to_paid_conversion=sub_stats.get('trial_to_paid_conversion', 0.0),
|
||||
),
|
||||
financial=FinancialStats(
|
||||
income_today_kopeks=income_today_kopeks,
|
||||
income_today_rubles=income_today_kopeks / 100,
|
||||
income_today_kopeks=trans_stats.get('today', {}).get('income_kopeks', 0),
|
||||
income_today_rubles=trans_stats.get('today', {}).get('income_kopeks', 0) / 100,
|
||||
income_month_kopeks=trans_stats.get('totals', {}).get('income_kopeks', 0),
|
||||
income_month_rubles=trans_stats.get('totals', {}).get('income_kopeks', 0) / 100,
|
||||
income_total_kopeks=all_time_stats.get('totals', {}).get('income_kopeks', 0),
|
||||
income_total_rubles=all_time_stats.get('totals', {}).get('income_kopeks', 0) / 100,
|
||||
subscription_income_kopeks=abs(all_time_stats.get('totals', {}).get('subscription_income_kopeks', 0)),
|
||||
subscription_income_rubles=abs(all_time_stats.get('totals', {}).get('subscription_income_kopeks', 0))
|
||||
/ 100,
|
||||
income_total_kopeks=trans_stats.get('totals', {}).get('income_kopeks', 0),
|
||||
income_total_rubles=trans_stats.get('totals', {}).get('income_kopeks', 0) / 100,
|
||||
subscription_income_kopeks=trans_stats.get('totals', {}).get('subscription_income_kopeks', 0),
|
||||
subscription_income_rubles=trans_stats.get('totals', {}).get('subscription_income_kopeks', 0) / 100,
|
||||
),
|
||||
servers=ServerStats(
|
||||
total_servers=server_stats.get('total_servers', 0),
|
||||
@@ -335,7 +326,7 @@ async def get_dashboard_stats(
|
||||
|
||||
@router.get('/system-info', response_model=SystemInfoResponse)
|
||||
async def get_system_info(
|
||||
admin: User = Depends(require_permission('stats:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get system information for admin dashboard."""
|
||||
@@ -367,7 +358,7 @@ async def get_system_info(
|
||||
|
||||
@router.get('/nodes', response_model=NodesOverview)
|
||||
async def get_nodes_status(
|
||||
admin: User = Depends(require_permission('stats:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get status of all nodes."""
|
||||
try:
|
||||
@@ -383,7 +374,7 @@ async def get_nodes_status(
|
||||
@router.post('/nodes/{node_uuid}/restart')
|
||||
async def restart_node(
|
||||
node_uuid: str,
|
||||
admin: User = Depends(require_permission('remnawave:manage')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Restart a node."""
|
||||
try:
|
||||
@@ -410,7 +401,7 @@ async def restart_node(
|
||||
@router.post('/nodes/{node_uuid}/toggle')
|
||||
async def toggle_node(
|
||||
node_uuid: str,
|
||||
admin: User = Depends(require_permission('remnawave:manage')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Enable or disable a node."""
|
||||
try:
|
||||
@@ -466,11 +457,15 @@ async def _get_nodes_overview() -> NodesOverview:
|
||||
is_disabled=n.get('is_disabled', False),
|
||||
users_online=n.get('users_online', 0) or 0,
|
||||
traffic_used_bytes=n.get('traffic_used_bytes'),
|
||||
uptime=n.get('uptime'),
|
||||
xray_version=n.get('xray_version'),
|
||||
node_version=n.get('node_version'),
|
||||
last_status_message=n.get('last_status_message'),
|
||||
xray_uptime=n.get('xray_uptime', 0) or 0,
|
||||
xray_uptime=n.get('xray_uptime'),
|
||||
is_xray_running=n.get('is_xray_running'),
|
||||
versions=n.get('versions'),
|
||||
system=n.get('system'),
|
||||
cpu_count=n.get('cpu_count'),
|
||||
cpu_model=n.get('cpu_model'),
|
||||
total_ram=n.get('total_ram'),
|
||||
country_code=n.get('country_code'),
|
||||
)
|
||||
for n in nodes
|
||||
@@ -508,7 +503,7 @@ async def _get_tariff_stats(db: AsyncSession) -> TariffStats | None:
|
||||
logger.info('📊 Нет тарифов в системе, пропускаем статистику')
|
||||
return None
|
||||
|
||||
now = datetime.now(UTC)
|
||||
now = datetime.utcnow()
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
month_ago = now - timedelta(days=30)
|
||||
@@ -601,12 +596,12 @@ async def _get_tariff_stats(db: AsyncSession) -> TariffStats | None:
|
||||
@router.get('/referrals/top', response_model=TopReferrersResponse)
|
||||
async def get_top_referrers(
|
||||
limit: int = 20,
|
||||
admin: User = Depends(require_permission('stats:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get top referrers with earnings breakdown by period."""
|
||||
try:
|
||||
now = datetime.now(UTC)
|
||||
now = datetime.utcnow()
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
month_ago = now - timedelta(days=30)
|
||||
@@ -691,6 +686,53 @@ async def get_top_referrers(
|
||||
if row.referrer_id in referrers_data:
|
||||
referrers_data[row.referrer_id]['earnings_month'] = row.total or 0
|
||||
|
||||
# Also add REFERRAL_REWARD transactions
|
||||
trans_total_query = await db.execute(
|
||||
select(Transaction.user_id.label('referrer_id'), func.sum(Transaction.amount_kopeks).label('total'))
|
||||
.where(Transaction.type == TransactionType.REFERRAL_REWARD.value)
|
||||
.group_by(Transaction.user_id)
|
||||
)
|
||||
for row in trans_total_query:
|
||||
if row.referrer_id in referrers_data:
|
||||
referrers_data[row.referrer_id]['earnings_total'] = referrers_data[row.referrer_id].get(
|
||||
'earnings_total', 0
|
||||
) + (row.total or 0)
|
||||
|
||||
trans_today_query = await db.execute(
|
||||
select(Transaction.user_id.label('referrer_id'), func.sum(Transaction.amount_kopeks).label('total'))
|
||||
.where(
|
||||
and_(Transaction.type == TransactionType.REFERRAL_REWARD.value, Transaction.created_at >= today_start)
|
||||
)
|
||||
.group_by(Transaction.user_id)
|
||||
)
|
||||
for row in trans_today_query:
|
||||
if row.referrer_id in referrers_data:
|
||||
referrers_data[row.referrer_id]['earnings_today'] = referrers_data[row.referrer_id].get(
|
||||
'earnings_today', 0
|
||||
) + (row.total or 0)
|
||||
|
||||
trans_week_query = await db.execute(
|
||||
select(Transaction.user_id.label('referrer_id'), func.sum(Transaction.amount_kopeks).label('total'))
|
||||
.where(and_(Transaction.type == TransactionType.REFERRAL_REWARD.value, Transaction.created_at >= week_ago))
|
||||
.group_by(Transaction.user_id)
|
||||
)
|
||||
for row in trans_week_query:
|
||||
if row.referrer_id in referrers_data:
|
||||
referrers_data[row.referrer_id]['earnings_week'] = referrers_data[row.referrer_id].get(
|
||||
'earnings_week', 0
|
||||
) + (row.total or 0)
|
||||
|
||||
trans_month_query = await db.execute(
|
||||
select(Transaction.user_id.label('referrer_id'), func.sum(Transaction.amount_kopeks).label('total'))
|
||||
.where(and_(Transaction.type == TransactionType.REFERRAL_REWARD.value, Transaction.created_at >= month_ago))
|
||||
.group_by(Transaction.user_id)
|
||||
)
|
||||
for row in trans_month_query:
|
||||
if row.referrer_id in referrers_data:
|
||||
referrers_data[row.referrer_id]['earnings_month'] = referrers_data[row.referrer_id].get(
|
||||
'earnings_month', 0
|
||||
) + (row.total or 0)
|
||||
|
||||
# Get user info for all referrers
|
||||
referrer_ids = list(referrers_data.keys())
|
||||
if referrer_ids:
|
||||
@@ -770,7 +812,7 @@ async def get_top_referrers(
|
||||
@router.get('/campaigns/top', response_model=TopCampaignsResponse)
|
||||
async def get_top_campaigns(
|
||||
limit: int = 20,
|
||||
admin: User = Depends(require_permission('stats:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get top advertising campaigns with statistics."""
|
||||
@@ -827,12 +869,12 @@ async def get_top_campaigns(
|
||||
@router.get('/payments/recent', response_model=RecentPaymentsResponse)
|
||||
async def get_recent_payments(
|
||||
limit: int = 50,
|
||||
admin: User = Depends(require_permission('stats:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get recent payments with user info."""
|
||||
try:
|
||||
now = datetime.now(UTC)
|
||||
now = datetime.utcnow()
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
|
||||
@@ -902,8 +944,8 @@ async def get_recent_payments(
|
||||
email=user.email,
|
||||
username=user.username,
|
||||
display_name=display_name,
|
||||
amount_kopeks=abs(trans.amount_kopeks),
|
||||
amount_rubles=abs(trans.amount_kopeks) / 100,
|
||||
amount_kopeks=trans.amount_kopeks,
|
||||
amount_rubles=trans.amount_kopeks / 100,
|
||||
type=trans.type,
|
||||
type_display=type_display.get(trans.type, trans.type),
|
||||
payment_method=trans.payment_method,
|
||||
@@ -927,24 +969,22 @@ async def get_recent_payments(
|
||||
total_count = total_count_result.scalar() or 0
|
||||
|
||||
today_total_result = await db.execute(
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
and_(
|
||||
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= today_start,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
)
|
||||
)
|
||||
)
|
||||
total_today = today_total_result.scalar() or 0
|
||||
|
||||
week_total_result = await db.execute(
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
and_(
|
||||
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= week_ago,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
"""Admin routes for managing tariffs in cabinet."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.server_squad import get_all_server_squads
|
||||
from app.database.crud.tariff import (
|
||||
create_tariff,
|
||||
@@ -21,16 +17,14 @@ from app.database.crud.tariff import (
|
||||
set_tariff_promo_groups,
|
||||
update_tariff,
|
||||
)
|
||||
from app.database.models import PromoGroup, Subscription, SubscriptionStatus, Tariff, Transaction, TransactionType, User
|
||||
from app.database.models import PromoGroup, Subscription, Tariff, Transaction, TransactionType, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.tariffs import (
|
||||
ExternalSquadInfoResponse,
|
||||
PeriodPrice,
|
||||
PromoGroupInfo,
|
||||
ServerInfo,
|
||||
ServerTrafficLimit,
|
||||
SyncSquadsResponse,
|
||||
TariffCreateRequest,
|
||||
TariffDetailResponse,
|
||||
TariffListItem,
|
||||
@@ -113,7 +107,7 @@ def _period_prices_to_dict(period_prices: list[PeriodPrice]) -> dict:
|
||||
@router.get('', response_model=TariffListResponse)
|
||||
async def list_tariffs(
|
||||
include_inactive: bool = True,
|
||||
admin: User = Depends(require_permission('tariffs:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of all tariffs."""
|
||||
@@ -132,7 +126,6 @@ async def list_tariffs(
|
||||
is_daily=tariff.is_daily,
|
||||
daily_price_kopeks=tariff.daily_price_kopeks,
|
||||
allow_traffic_topup=tariff.allow_traffic_topup,
|
||||
show_in_gift=tariff.show_in_gift,
|
||||
traffic_limit_gb=tariff.traffic_limit_gb,
|
||||
device_limit=tariff.device_limit,
|
||||
tier_level=tariff.tier_level,
|
||||
@@ -148,7 +141,7 @@ async def list_tariffs(
|
||||
|
||||
@router.get('/available-servers', response_model=list[ServerInfo])
|
||||
async def get_available_servers(
|
||||
admin: User = Depends(require_permission('tariffs:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of all servers for tariff selection."""
|
||||
@@ -165,34 +158,10 @@ async def get_available_servers(
|
||||
]
|
||||
|
||||
|
||||
@router.get('/available-external-squads', response_model=list[ExternalSquadInfoResponse])
|
||||
async def get_available_external_squads(
|
||||
admin: User = Depends(require_permission('tariffs:read')),
|
||||
):
|
||||
"""Fetch external squads from RemnaWave panel."""
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
try:
|
||||
service = RemnaWaveService()
|
||||
async with service.get_api_client() as api:
|
||||
squads = await api.get_external_squads()
|
||||
return [
|
||||
{
|
||||
'uuid': s.uuid,
|
||||
'name': s.name,
|
||||
'members_count': s.members_count,
|
||||
}
|
||||
for s in squads
|
||||
]
|
||||
except Exception:
|
||||
logger.warning('Failed to fetch external squads from RemnaWave', exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
@router.put('/order')
|
||||
async def update_tariff_order(
|
||||
request: TariffSortOrderRequest,
|
||||
admin: User = Depends(require_permission('tariffs:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update the display order of tariffs."""
|
||||
@@ -207,7 +176,7 @@ async def update_tariff_order(
|
||||
@router.get('/{tariff_id}', response_model=TariffDetailResponse)
|
||||
async def get_tariff(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(require_permission('tariffs:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get detailed tariff info."""
|
||||
@@ -269,10 +238,6 @@ async def get_tariff(
|
||||
daily_price_kopeks=tariff.daily_price_kopeks,
|
||||
# Режим сброса трафика
|
||||
traffic_reset_mode=tariff.traffic_reset_mode,
|
||||
# Внешний сквад
|
||||
external_squad_uuid=tariff.external_squad_uuid,
|
||||
# Показывать в подарках
|
||||
show_in_gift=tariff.show_in_gift,
|
||||
created_at=tariff.created_at,
|
||||
updated_at=tariff.updated_at,
|
||||
)
|
||||
@@ -281,7 +246,7 @@ async def get_tariff(
|
||||
@router.post('', response_model=TariffDetailResponse)
|
||||
async def create_new_tariff(
|
||||
request: TariffCreateRequest,
|
||||
admin: User = Depends(require_permission('tariffs:create')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Create a new tariff."""
|
||||
@@ -311,7 +276,7 @@ async def create_new_tariff(
|
||||
period_prices=period_prices_dict,
|
||||
allowed_squads=request.allowed_squads,
|
||||
server_traffic_limits=server_limits_dict,
|
||||
promo_group_ids=request.promo_group_ids or None,
|
||||
promo_group_ids=request.promo_group_ids if request.promo_group_ids else None,
|
||||
# Произвольное количество дней
|
||||
custom_days_enabled=request.custom_days_enabled,
|
||||
price_per_day_kopeks=request.price_per_day_kopeks,
|
||||
@@ -327,10 +292,6 @@ async def create_new_tariff(
|
||||
daily_price_kopeks=request.daily_price_kopeks,
|
||||
# Режим сброса трафика
|
||||
traffic_reset_mode=request.traffic_reset_mode,
|
||||
# Внешний сквад
|
||||
external_squad_uuid=request.external_squad_uuid,
|
||||
# Показывать в подарках
|
||||
show_in_gift=request.show_in_gift,
|
||||
)
|
||||
|
||||
logger.info('Admin created tariff', admin_id=admin.id, tariff_id=tariff.id, tariff_name=tariff.name)
|
||||
@@ -346,7 +307,7 @@ async def create_new_tariff(
|
||||
async def update_existing_tariff(
|
||||
tariff_id: int,
|
||||
request: TariffUpdateRequest,
|
||||
admin: User = Depends(require_permission('tariffs:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update an existing tariff."""
|
||||
@@ -357,10 +318,6 @@ async def update_existing_tariff(
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Capture old values for change detection
|
||||
old_squads = list(tariff.allowed_squads) if tariff.allowed_squads else []
|
||||
old_external_squad = tariff.external_squad_uuid
|
||||
|
||||
# Build updates dict
|
||||
updates = {}
|
||||
if request.name is not None:
|
||||
@@ -424,12 +381,6 @@ async def update_existing_tariff(
|
||||
# Режим сброса трафика (None допускается как значение для сброса к глобальной настройке)
|
||||
if 'traffic_reset_mode' in request.model_fields_set:
|
||||
updates['traffic_reset_mode'] = request.traffic_reset_mode
|
||||
# Внешний сквад (None допускается для сброса)
|
||||
if 'external_squad_uuid' in request.model_fields_set:
|
||||
updates['external_squad_uuid'] = request.external_squad_uuid
|
||||
# Показывать в подарках
|
||||
if request.show_in_gift is not None:
|
||||
updates['show_in_gift'] = request.show_in_gift
|
||||
|
||||
if updates:
|
||||
await update_tariff(db, tariff, **updates)
|
||||
@@ -443,25 +394,13 @@ async def update_existing_tariff(
|
||||
# Перезагружаем периоды из БД для синхронизации с ботом
|
||||
await load_period_prices_from_db(db)
|
||||
|
||||
# Auto-sync squads to active subscriptions in Remnawave when squads changed
|
||||
new_squads = tariff.allowed_squads or []
|
||||
squads_changed = request.allowed_squads is not None and sorted(old_squads) != sorted(new_squads)
|
||||
ext_squad_changed = (
|
||||
'external_squad_uuid' in request.model_fields_set and tariff.external_squad_uuid != old_external_squad
|
||||
)
|
||||
if squads_changed or ext_squad_changed:
|
||||
asyncio.create_task(
|
||||
_background_sync_squads(tariff_id, admin.id),
|
||||
name=f'sync-squads-tariff-{tariff_id}',
|
||||
)
|
||||
|
||||
return await get_tariff(tariff_id, admin, db)
|
||||
|
||||
|
||||
@router.delete('/{tariff_id}')
|
||||
async def delete_existing_tariff(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(require_permission('tariffs:delete')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Delete a tariff."""
|
||||
@@ -491,7 +430,7 @@ async def delete_existing_tariff(
|
||||
@router.post('/{tariff_id}/toggle', response_model=TariffToggleResponse)
|
||||
async def toggle_tariff(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(require_permission('tariffs:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Toggle tariff active status."""
|
||||
@@ -521,7 +460,7 @@ async def toggle_tariff(
|
||||
@router.post('/{tariff_id}/trial', response_model=TariffTrialResponse)
|
||||
async def toggle_trial_tariff(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(require_permission('tariffs:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Toggle tariff trial availability.
|
||||
@@ -561,7 +500,7 @@ async def toggle_trial_tariff(
|
||||
@router.get('/{tariff_id}/stats', response_model=TariffStatsResponse)
|
||||
async def get_tariff_stats(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(require_permission('tariffs:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get tariff statistics."""
|
||||
@@ -596,7 +535,7 @@ async def get_tariff_stats(
|
||||
|
||||
# Calculate revenue from subscription payments for users on this tariff
|
||||
revenue_result = await db.execute(
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0))
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.join(Subscription, Transaction.user_id == Subscription.user_id)
|
||||
.where(
|
||||
Subscription.tariff_id == tariff_id,
|
||||
@@ -615,225 +554,3 @@ async def get_tariff_stats(
|
||||
revenue_kopeks=revenue_kopeks,
|
||||
revenue_rubles=revenue_kopeks / 100,
|
||||
)
|
||||
|
||||
|
||||
async def _background_sync_squads(tariff_id: int, admin_id: int) -> None:
|
||||
"""Run squad sync in background with its own DB session (fire-and-forget)."""
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
if not tariff:
|
||||
return
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.join(User, Subscription.user_id == User.id)
|
||||
.options(joinedload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.tariff_id == tariff_id,
|
||||
Subscription.status.in_([SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]),
|
||||
User.remnawave_uuid.isnot(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
subscriptions = list(result.unique().scalars().all())
|
||||
|
||||
if not subscriptions:
|
||||
return
|
||||
|
||||
new_squads = tariff.allowed_squads or []
|
||||
ext_squad_uuid = tariff.external_squad_uuid
|
||||
|
||||
service = RemnaWaveService()
|
||||
updated = 0
|
||||
failed = 0
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
semaphore = asyncio.Semaphore(5)
|
||||
|
||||
async def _sync_one(sub: Subscription) -> None:
|
||||
nonlocal updated, failed
|
||||
remnawave_uuid = (
|
||||
getattr(sub, 'remnawave_uuid', None)
|
||||
if settings.is_multi_tariff_enabled()
|
||||
else (sub.user.remnawave_uuid if sub.user else None)
|
||||
)
|
||||
if not remnawave_uuid:
|
||||
return
|
||||
async with semaphore:
|
||||
try:
|
||||
await api.update_user(
|
||||
uuid=remnawave_uuid,
|
||||
active_internal_squads=new_squads,
|
||||
external_squad_uuid=ext_squad_uuid,
|
||||
)
|
||||
sub.connected_squads = new_squads
|
||||
updated += 1
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
logger.warning(
|
||||
'Background sync: failed to sync squads for user',
|
||||
user_id=sub.user_id,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
await asyncio.gather(*[_sync_one(sub) for sub in subscriptions])
|
||||
|
||||
await db.commit()
|
||||
logger.info(
|
||||
'Background squad sync completed after tariff update',
|
||||
admin_id=admin_id,
|
||||
tariff_id=tariff_id,
|
||||
tariff_name=tariff.name,
|
||||
total=len(subscriptions),
|
||||
updated=updated,
|
||||
failed=failed,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Background squad sync failed', tariff_id=tariff_id)
|
||||
|
||||
|
||||
_SYNC_SQUADS_CONCURRENCY = 5
|
||||
_SYNC_SQUADS_MAX_CONSECUTIVE_FAILURES = 10
|
||||
|
||||
|
||||
@router.post('/{tariff_id}/sync-squads', response_model=SyncSquadsResponse)
|
||||
async def sync_tariff_squads(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(require_permission('tariffs:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Sync squads from tariff to all active/trial subscriptions in Remnawave panel.
|
||||
|
||||
Updates connected_squads and external_squad_uuid for every active or trial
|
||||
subscription linked to this tariff. Only users that have a remnawave_uuid
|
||||
(i.e. already exist in the panel) are touched.
|
||||
"""
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
if not tariff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Fetch active + trial subscriptions for this tariff whose users exist in Remnawave
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.join(User, Subscription.user_id == User.id)
|
||||
.options(joinedload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.tariff_id == tariff_id,
|
||||
Subscription.status.in_([SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]),
|
||||
User.remnawave_uuid.isnot(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
subscriptions = list(result.unique().scalars().all())
|
||||
|
||||
if not subscriptions:
|
||||
return SyncSquadsResponse(
|
||||
tariff_id=tariff_id,
|
||||
tariff_name=tariff.name,
|
||||
total_subscriptions=0,
|
||||
updated_count=0,
|
||||
failed_count=0,
|
||||
skipped_count=0,
|
||||
)
|
||||
|
||||
new_squads = tariff.allowed_squads or []
|
||||
# None means "clear external squad" — intentional when tariff has none
|
||||
ext_squad_uuid = tariff.external_squad_uuid
|
||||
|
||||
# Sync to Remnawave panel with concurrency limit and circuit breaker
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
service = RemnaWaveService()
|
||||
updated_count = 0
|
||||
failed_count = 0
|
||||
skipped_count = 0
|
||||
consecutive_failures = 0
|
||||
errors: list[str] = []
|
||||
aborted = False
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
semaphore = asyncio.Semaphore(_SYNC_SQUADS_CONCURRENCY)
|
||||
|
||||
async def _sync_one(sub: Subscription) -> str:
|
||||
# Counter mutations are safe: no `await` between read-modify-write
|
||||
# and the check within each branch (single-threaded asyncio event loop).
|
||||
nonlocal updated_count, failed_count, skipped_count, consecutive_failures, aborted
|
||||
|
||||
if aborted:
|
||||
skipped_count += 1
|
||||
return 'skipped'
|
||||
|
||||
remnawave_uuid = (
|
||||
getattr(sub, 'remnawave_uuid', None)
|
||||
if settings.is_multi_tariff_enabled()
|
||||
else (sub.user.remnawave_uuid if sub.user else None)
|
||||
)
|
||||
if not remnawave_uuid:
|
||||
skipped_count += 1
|
||||
return 'skipped'
|
||||
|
||||
async with semaphore:
|
||||
if aborted:
|
||||
skipped_count += 1
|
||||
return 'skipped'
|
||||
|
||||
try:
|
||||
await api.update_user(
|
||||
uuid=remnawave_uuid,
|
||||
active_internal_squads=new_squads,
|
||||
external_squad_uuid=ext_squad_uuid,
|
||||
)
|
||||
# Update local DB only on successful API call
|
||||
sub.connected_squads = new_squads
|
||||
updated_count += 1
|
||||
consecutive_failures = 0
|
||||
return 'ok'
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
consecutive_failures += 1
|
||||
errors.append(f'user_id={sub.user_id}: sync failed')
|
||||
logger.warning(
|
||||
'Failed to sync squads for user in Remnawave',
|
||||
user_id=sub.user_id,
|
||||
remnawave_uuid=remnawave_uuid,
|
||||
error=str(e),
|
||||
)
|
||||
if consecutive_failures >= _SYNC_SQUADS_MAX_CONSECUTIVE_FAILURES:
|
||||
aborted = True
|
||||
errors.append(f'Aborted after {_SYNC_SQUADS_MAX_CONSECUTIVE_FAILURES} consecutive failures')
|
||||
return 'error'
|
||||
|
||||
await asyncio.gather(*[_sync_one(sub) for sub in subscriptions])
|
||||
|
||||
# Commit local DB changes only for successfully synced subscriptions
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Admin synced squads for tariff',
|
||||
admin_id=admin.id,
|
||||
tariff_id=tariff_id,
|
||||
tariff_name=tariff.name,
|
||||
total=len(subscriptions),
|
||||
updated=updated_count,
|
||||
failed=failed_count,
|
||||
skipped=skipped_count,
|
||||
)
|
||||
|
||||
return SyncSquadsResponse(
|
||||
tariff_id=tariff_id,
|
||||
tariff_name=tariff.name,
|
||||
total_subscriptions=len(subscriptions),
|
||||
updated_count=updated_count,
|
||||
failed_count=failed_count,
|
||||
skipped_count=skipped_count,
|
||||
errors=errors[:20],
|
||||
)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Admin tickets routes for cabinet."""
|
||||
|
||||
import math
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -16,8 +16,8 @@ from app.database.crud.ticket import TicketCRUD
|
||||
from app.database.crud.ticket_notification import TicketNotificationCRUD
|
||||
from app.database.models import Ticket, TicketMessage, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.tickets import TicketMediaItem, TicketMessageResponse, _validate_media_bundle
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.tickets import TicketMessageResponse
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -89,20 +89,7 @@ class AdminTicketListResponse(BaseModel):
|
||||
class AdminReplyRequest(BaseModel):
|
||||
"""Admin reply to ticket."""
|
||||
|
||||
message: str = Field(default='', max_length=4000, description='Reply message')
|
||||
media_type: str | None = Field(None, description='Media type: photo, video, or document')
|
||||
media_file_id: str | None = Field(None, max_length=255, description='Telegram file_id from media upload')
|
||||
media_caption: str | None = Field(None, max_length=1000, description='Caption for media')
|
||||
media_items: list[TicketMediaItem] | None = Field(None, description='Multi-media gallery attachments')
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_media_fields(self) -> 'AdminReplyRequest':
|
||||
_validate_media_bundle(self.media_type, self.media_file_id, self.media_items)
|
||||
has_text = bool(self.message.strip())
|
||||
has_media = bool(self.media_file_id) or bool(self.media_items)
|
||||
if not has_text and not has_media:
|
||||
raise ValueError('message or media is required')
|
||||
return self
|
||||
message: str = Field(..., min_length=1, max_length=4000, description='Reply message')
|
||||
|
||||
|
||||
class AdminStatusUpdateRequest(BaseModel):
|
||||
@@ -157,23 +144,14 @@ class TicketSettingsUpdateRequest(BaseModel):
|
||||
|
||||
def _message_to_response(message: TicketMessage) -> TicketMessageResponse:
|
||||
"""Convert TicketMessage to response."""
|
||||
raw_items = getattr(message, 'media_items', None) or None
|
||||
items = None
|
||||
if raw_items:
|
||||
try:
|
||||
items = [TicketMediaItem(**it) for it in raw_items]
|
||||
except (TypeError, KeyError, ValueError) as exc:
|
||||
logger.warning('Failed to parse media_items', message_id=message.id, error=str(exc))
|
||||
items = None
|
||||
return TicketMessageResponse(
|
||||
id=message.id,
|
||||
message_text=message.message_text or '',
|
||||
is_from_admin=message.is_from_admin,
|
||||
has_media=bool(message.media_file_id) or bool(items),
|
||||
has_media=bool(message.media_file_id),
|
||||
media_type=message.media_type,
|
||||
media_file_id=message.media_file_id,
|
||||
media_caption=message.media_caption,
|
||||
media_items=items,
|
||||
created_at=message.created_at,
|
||||
)
|
||||
|
||||
@@ -219,7 +197,7 @@ def _ticket_to_admin_response(ticket: Ticket, include_messages: bool = False) ->
|
||||
|
||||
@router.get('/stats', response_model=AdminStatsResponse)
|
||||
async def get_ticket_stats(
|
||||
admin: User = Depends(require_permission('tickets:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get ticket statistics."""
|
||||
@@ -244,7 +222,7 @@ async def get_ticket_stats(
|
||||
|
||||
@router.get('/settings', response_model=TicketSettingsResponse)
|
||||
async def get_ticket_settings(
|
||||
admin: User = Depends(require_permission('tickets:settings')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get ticket system settings."""
|
||||
@@ -264,11 +242,10 @@ async def get_ticket_settings(
|
||||
@router.patch('/settings', response_model=TicketSettingsResponse)
|
||||
async def update_ticket_settings(
|
||||
request: TicketSettingsUpdateRequest,
|
||||
admin: User = Depends(require_permission('tickets:settings')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update ticket system settings."""
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
@@ -292,7 +269,7 @@ async def update_ticket_settings(
|
||||
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:
|
||||
SupportSettingsService.set_system_mode(request.support_system_mode.strip().lower())
|
||||
settings.SUPPORT_SYSTEM_MODE = request.support_system_mode.strip().lower()
|
||||
|
||||
# Update cabinet notification settings
|
||||
if request.cabinet_user_notifications_enabled is not None:
|
||||
@@ -303,8 +280,8 @@ async def update_ticket_settings(
|
||||
# Try to persist to .env file
|
||||
try:
|
||||
env_file = Path('.env')
|
||||
if await asyncio.to_thread(env_file.exists):
|
||||
lines = (await asyncio.to_thread(env_file.read_text)).splitlines()
|
||||
if env_file.exists():
|
||||
lines = env_file.read_text().splitlines()
|
||||
updates = {}
|
||||
|
||||
if request.sla_enabled is not None:
|
||||
@@ -337,7 +314,7 @@ async def update_ticket_settings(
|
||||
if key not in updated_keys:
|
||||
new_lines.append(f'{key}={value}')
|
||||
|
||||
await asyncio.to_thread(env_file.write_text, '\n'.join(new_lines) + '\n')
|
||||
env_file.write_text('\n'.join(new_lines) + '\n')
|
||||
logger.info('Updated ticket settings in .env file')
|
||||
except Exception as e:
|
||||
logger.warning('Failed to update .env file', error=e)
|
||||
@@ -360,7 +337,7 @@ async def get_all_tickets(
|
||||
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(require_permission('tickets:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get all tickets for admin."""
|
||||
@@ -409,7 +386,7 @@ async def get_all_tickets(
|
||||
@router.get('/{ticket_id}', response_model=AdminTicketDetailResponse)
|
||||
async def get_ticket_detail(
|
||||
ticket_id: int,
|
||||
admin: User = Depends(require_permission('tickets:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get ticket with all messages for admin."""
|
||||
@@ -451,7 +428,7 @@ async def get_ticket_detail(
|
||||
async def reply_to_ticket(
|
||||
ticket_id: int,
|
||||
request: AdminReplyRequest,
|
||||
admin: User = Depends(require_permission('tickets:reply')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reply to a ticket as admin."""
|
||||
@@ -464,45 +441,33 @@ async def reply_to_ticket(
|
||||
detail='Ticket not found',
|
||||
)
|
||||
|
||||
# Resolve media payload: prefer media_items, fall back to legacy single-media fields
|
||||
items_payload = None
|
||||
primary_type = request.media_type
|
||||
primary_file_id = request.media_file_id
|
||||
primary_caption = request.media_caption
|
||||
if request.media_items:
|
||||
items_payload = [it.model_dump() for it in request.media_items]
|
||||
first = request.media_items[0]
|
||||
primary_type = first.type
|
||||
primary_file_id = first.file_id
|
||||
primary_caption = primary_caption or first.caption
|
||||
has_media = bool(primary_file_id)
|
||||
|
||||
# Create admin message
|
||||
message = TicketMessage(
|
||||
ticket_id=ticket.id,
|
||||
user_id=ticket.user_id,
|
||||
message_text=request.message,
|
||||
is_from_admin=True,
|
||||
has_media=has_media,
|
||||
media_type=primary_type if has_media else None,
|
||||
media_file_id=primary_file_id if has_media else None,
|
||||
media_caption=primary_caption if has_media else None,
|
||||
media_items=items_payload,
|
||||
created_at=datetime.now(UTC),
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(message)
|
||||
|
||||
# Update ticket status to answered
|
||||
ticket.status = 'answered'
|
||||
ticket.updated_at = datetime.now(UTC)
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
|
||||
# Try to notify user via Telegram
|
||||
try:
|
||||
from app.bot_factory import create_bot
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
|
||||
bot = create_bot()
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
try:
|
||||
from app.handlers.admin.tickets import notify_user_about_ticket_reply
|
||||
|
||||
@@ -532,7 +497,7 @@ async def reply_to_ticket(
|
||||
async def update_ticket_status(
|
||||
ticket_id: int,
|
||||
request: AdminStatusUpdateRequest,
|
||||
admin: User = Depends(require_permission('tickets:close')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update ticket status."""
|
||||
@@ -557,9 +522,9 @@ async def update_ticket_status(
|
||||
)
|
||||
|
||||
ticket.status = request.status
|
||||
ticket.updated_at = datetime.now(UTC)
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
if request.status == 'closed':
|
||||
ticket.closed_at = datetime.now(UTC)
|
||||
ticket.closed_at = datetime.utcnow()
|
||||
else:
|
||||
ticket.closed_at = None
|
||||
|
||||
@@ -591,7 +556,7 @@ async def update_ticket_status(
|
||||
async def update_ticket_priority(
|
||||
ticket_id: int,
|
||||
request: AdminPriorityUpdateRequest,
|
||||
admin: User = Depends(require_permission('tickets:close')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update ticket priority."""
|
||||
@@ -616,7 +581,7 @@ async def update_ticket_priority(
|
||||
)
|
||||
|
||||
ticket.priority = request.priority
|
||||
ticket.updated_at = datetime.now(UTC)
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(ticket)
|
||||
|
||||
@@ -7,22 +7,23 @@ import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.types import BufferedInputFile
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
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, require_permission
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.traffic import (
|
||||
ExportCsvRequest,
|
||||
ExportCsvResponse,
|
||||
SubscriptionEnrichmentInfo,
|
||||
SubscriptionTrafficInfo,
|
||||
TrafficEnrichmentResponse,
|
||||
TrafficNodeInfo,
|
||||
TrafficUsageResponse,
|
||||
@@ -98,13 +99,7 @@ async def _aggregate_traffic(
|
||||
user_uuids_set = set(user_uuids)
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
try:
|
||||
nodes = await api.get_all_nodes()
|
||||
except Exception:
|
||||
logger.warning('Failed to fetch nodes for traffic aggregation', exc_info=True)
|
||||
# Cache empty result to avoid hammering the failing API
|
||||
_traffic_cache[cache_key] = (now, {}, [])
|
||||
return {}, []
|
||||
nodes = await api.get_all_nodes()
|
||||
|
||||
# Fetch per-node user stats — O(nodes) calls instead of O(users)
|
||||
semaphore = asyncio.Semaphore(_CONCURRENCY_LIMIT)
|
||||
@@ -158,42 +153,15 @@ def _compute_date_range(period_days: int) -> tuple[str, str]:
|
||||
|
||||
|
||||
async def _load_user_map(db: AsyncSession) -> dict[str, User]:
|
||||
"""Load all users with remnawave_uuid, eagerly loading subscription + tariff.
|
||||
|
||||
In multi-tariff mode UUIDs live on Subscription rows, not on User.
|
||||
Both sources are merged so the caller gets a complete uuid → User map.
|
||||
"""
|
||||
from app.config import settings
|
||||
|
||||
# Build user map from both user-level and subscription-level UUIDs
|
||||
user_map: dict[str, User] = {}
|
||||
|
||||
# Legacy: user-level UUIDs
|
||||
stmt_users = (
|
||||
"""Load all users with remnawave_uuid, eagerly loading subscription + tariff."""
|
||||
stmt = (
|
||||
select(User)
|
||||
.where(User.remnawave_uuid.isnot(None))
|
||||
.options(selectinload(User.subscriptions).selectinload(Subscription.tariff))
|
||||
.options(selectinload(User.subscription).selectinload(Subscription.tariff))
|
||||
)
|
||||
result_users = await db.execute(stmt_users)
|
||||
users = result_users.scalars().all()
|
||||
for u in users:
|
||||
if u.remnawave_uuid:
|
||||
user_map[u.remnawave_uuid] = u
|
||||
|
||||
# Multi-tariff: subscription-level UUIDs
|
||||
if settings.is_multi_tariff_enabled():
|
||||
stmt_subs = (
|
||||
select(Subscription)
|
||||
.where(Subscription.remnawave_uuid.isnot(None))
|
||||
.options(selectinload(Subscription.user).selectinload(User.subscriptions).selectinload(Subscription.tariff))
|
||||
)
|
||||
result_subs = await db.execute(stmt_subs)
|
||||
subs = result_subs.scalars().all()
|
||||
for sub in subs:
|
||||
if sub.remnawave_uuid and sub.user and sub.remnawave_uuid not in user_map:
|
||||
user_map[sub.remnawave_uuid] = sub.user
|
||||
|
||||
return user_map
|
||||
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(
|
||||
@@ -231,23 +199,19 @@ def _build_traffic_items(
|
||||
):
|
||||
continue
|
||||
|
||||
subs = getattr(user, 'subscriptions', None) or []
|
||||
|
||||
# Primary subscription for backward-compat top-level fields
|
||||
primary_sub = next((s for s in subs if s.is_active), subs[0] if subs else None)
|
||||
sub = user.subscription
|
||||
tariff_name = None
|
||||
subscription_status = None
|
||||
traffic_limit_gb = 0.0
|
||||
device_limit = 1
|
||||
|
||||
if primary_sub:
|
||||
subscription_status = _get_status(primary_sub)
|
||||
traffic_limit_gb = float(primary_sub.traffic_limit_gb or 0)
|
||||
device_limit = primary_sub.device_limit or 1
|
||||
if primary_sub.tariff:
|
||||
tariff_name = primary_sub.tariff.name
|
||||
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
|
||||
|
||||
# Filtering uses primary sub values (keeps existing filter semantics)
|
||||
if tariff_filter is not None:
|
||||
if (tariff_name or '') not in tariff_filter:
|
||||
continue
|
||||
@@ -262,18 +226,6 @@ def _build_traffic_items(
|
||||
|
||||
total_bytes = sum(traffic.values())
|
||||
|
||||
# Build per-subscription detail list for multi-subscription display
|
||||
subscriptions_traffic = [
|
||||
SubscriptionTrafficInfo(
|
||||
subscription_id=sub.id,
|
||||
tariff_name=sub.tariff.name if sub.tariff else None,
|
||||
status=_get_status(sub),
|
||||
traffic_limit_gb=float(sub.traffic_limit_gb or 0),
|
||||
device_limit=sub.device_limit or 1,
|
||||
)
|
||||
for sub in subs
|
||||
]
|
||||
|
||||
items.append(
|
||||
UserTrafficItem(
|
||||
user_id=user.id,
|
||||
@@ -287,7 +239,6 @@ def _build_traffic_items(
|
||||
device_limit=device_limit,
|
||||
node_traffic=traffic,
|
||||
total_bytes=total_bytes,
|
||||
subscriptions=subscriptions_traffic,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -305,7 +256,7 @@ def _build_traffic_items(
|
||||
|
||||
@router.get('', response_model=TrafficUsageResponse)
|
||||
async def get_traffic_usage(
|
||||
admin: User = Depends(require_permission('traffic:read')),
|
||||
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),
|
||||
@@ -351,21 +302,15 @@ async def get_traffic_usage(
|
||||
# Collect all available tariff names (before filtering)
|
||||
available_tariffs = sorted(
|
||||
{
|
||||
sub.tariff.name
|
||||
u.subscription.tariff.name
|
||||
for u in user_map.values()
|
||||
for sub in (getattr(u, 'subscriptions', None) or [])
|
||||
if sub.tariff and sub.tariff.name
|
||||
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()
|
||||
for sub in (getattr(u, 'subscriptions', None) or [])
|
||||
if _get_status(sub)
|
||||
}
|
||||
{_get_status(sub) for u in user_map.values() if (sub := u.subscription) and _get_status(sub)}
|
||||
)
|
||||
|
||||
# Parse tariff filter
|
||||
@@ -438,7 +383,7 @@ async def _get_bulk_spending(db: AsyncSession, user_ids: list[int]) -> dict[int,
|
||||
if not user_ids:
|
||||
return {}
|
||||
result = await db.execute(
|
||||
select(Transaction.user_id, func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0))
|
||||
select(Transaction.user_id, func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.user_id.in_(user_ids),
|
||||
@@ -518,42 +463,27 @@ async def _build_enrichment(db: AsyncSession, user_map: dict[str, User]) -> dict
|
||||
enrichment: dict[int, UserTrafficEnrichment] = {}
|
||||
for uuid, user in user_map.items():
|
||||
uid = user.id
|
||||
subs_list = getattr(user, 'subscriptions', None) or []
|
||||
|
||||
# Primary subscription for backward-compat top-level date fields
|
||||
primary_sub = next((s for s in subs_list if s.is_active), subs_list[0] if subs_list else None)
|
||||
sub = user.subscription
|
||||
|
||||
start_date = None
|
||||
end_date = None
|
||||
if primary_sub:
|
||||
if primary_sub.start_date:
|
||||
start_date = primary_sub.start_date.isoformat()
|
||||
if primary_sub.end_date:
|
||||
end_date = primary_sub.end_date.isoformat()
|
||||
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)
|
||||
|
||||
# Build per-subscription enrichment list for multi-subscription display
|
||||
subscriptions_enrichment = [
|
||||
SubscriptionEnrichmentInfo(
|
||||
subscription_id=sub.id,
|
||||
tariff_name=sub.tariff.name if sub.tariff else None,
|
||||
start_date=sub.start_date.isoformat() if sub.start_date else None,
|
||||
end_date=sub.end_date.isoformat() if sub.end_date else None,
|
||||
)
|
||||
for sub in subs_list
|
||||
]
|
||||
|
||||
enrichment[uid] = UserTrafficEnrichment(
|
||||
devices_connected=devices_by_user.get(uid, 0),
|
||||
total_spent_kopeks=spending_map.get(uid, 0),
|
||||
subscription_start_date=start_date,
|
||||
subscription_end_date=end_date,
|
||||
last_node_name=last_node_name,
|
||||
subscriptions=subscriptions_enrichment,
|
||||
)
|
||||
|
||||
return enrichment
|
||||
@@ -561,7 +491,7 @@ async def _build_enrichment(db: AsyncSession, user_map: dict[str, User]) -> dict
|
||||
|
||||
@router.get('/enrichment', response_model=TrafficEnrichmentResponse)
|
||||
async def get_traffic_enrichment(
|
||||
admin: User = Depends(require_permission('traffic:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Return enrichment data: device counts, spending, dates, last node."""
|
||||
@@ -594,7 +524,7 @@ async def get_traffic_enrichment(
|
||||
@router.post('/export-csv', response_model=ExportCsvResponse)
|
||||
async def export_traffic_csv(
|
||||
request: ExportCsvRequest,
|
||||
admin: User = Depends(require_permission('traffic:export')),
|
||||
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."""
|
||||
@@ -744,7 +674,10 @@ async def export_traffic_csv(
|
||||
filename = f'traffic_usage_{period_label}_{timestamp}.csv'
|
||||
|
||||
try:
|
||||
bot = create_bot()
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
async with bot:
|
||||
await bot.send_document(
|
||||
chat_id=admin.telegram_id,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Admin routes for version and release information."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import aiohttp
|
||||
import structlog
|
||||
@@ -10,7 +10,7 @@ from pydantic import BaseModel
|
||||
from app.database.models import User
|
||||
from app.services.version_service import version_service
|
||||
|
||||
from ..dependencies import require_permission
|
||||
from ..dependencies import get_current_admin_user
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -53,7 +53,7 @@ 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(UTC) - _cabinet_last_check < timedelta(seconds=_CACHE_TTL):
|
||||
if datetime.now() - _cabinet_last_check < timedelta(seconds=_CACHE_TTL):
|
||||
return _cabinet_cache['releases']
|
||||
|
||||
url = f'https://api.github.com/repos/{CABINET_REPO}/releases'
|
||||
@@ -75,7 +75,7 @@ async def _fetch_cabinet_releases(force: bool = False) -> list[dict]:
|
||||
}
|
||||
)
|
||||
_cabinet_cache['releases'] = releases
|
||||
_cabinet_last_check = datetime.now(UTC)
|
||||
_cabinet_last_check = datetime.now()
|
||||
logger.info('Fetched cabinet releases from GitHub', releases_count=len(releases))
|
||||
return releases
|
||||
logger.warning('GitHub API returned status for cabinet releases', response_status=response.status)
|
||||
@@ -93,7 +93,7 @@ async def _fetch_cabinet_releases(force: bool = False) -> list[dict]:
|
||||
|
||||
@router.get('/releases', response_model=ReleasesResponse)
|
||||
async def get_releases(
|
||||
current_user: User = Depends(require_permission('updates:read')),
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
) -> ReleasesResponse:
|
||||
"""Get release information for bot and cabinet."""
|
||||
# Bot releases
|
||||
|
||||
+247
-1226
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@ import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.cabinet.dependencies import get_cabinet_db, require_permission
|
||||
from app.cabinet.dependencies import get_cabinet_db, get_current_admin_user
|
||||
from app.cabinet.schemas.wheel import (
|
||||
AdminSpinItem,
|
||||
AdminSpinsResponse,
|
||||
@@ -42,7 +42,7 @@ router = APIRouter(prefix='/admin/wheel', tags=['Admin Fortune Wheel'])
|
||||
|
||||
@router.get('/config', response_model=AdminWheelConfigResponse)
|
||||
async def get_admin_wheel_config(
|
||||
admin: User = Depends(require_permission('wheel:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Получить полную конфигурацию колеса."""
|
||||
@@ -93,7 +93,7 @@ async def get_admin_wheel_config(
|
||||
@router.put('/config', response_model=AdminWheelConfigResponse)
|
||||
async def update_admin_wheel_config(
|
||||
request: UpdateWheelConfigRequest,
|
||||
admin: User = Depends(require_permission('wheel:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Обновить конфигурацию колеса."""
|
||||
@@ -155,7 +155,7 @@ async def update_admin_wheel_config(
|
||||
|
||||
@router.get('/prizes', response_model=list[WheelPrizeAdminResponse])
|
||||
async def get_prizes(
|
||||
admin: User = Depends(require_permission('wheel:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Получить список призов."""
|
||||
@@ -188,7 +188,7 @@ async def get_prizes(
|
||||
@router.post('/prizes', response_model=WheelPrizeAdminResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_prize(
|
||||
request: CreatePrizeRequest,
|
||||
admin: User = Depends(require_permission('wheel:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Создать новый приз."""
|
||||
@@ -237,7 +237,7 @@ async def create_prize(
|
||||
async def update_prize(
|
||||
prize_id: int,
|
||||
request: UpdatePrizeRequest,
|
||||
admin: User = Depends(require_permission('wheel:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Обновить приз."""
|
||||
@@ -286,7 +286,7 @@ async def update_prize(
|
||||
@router.delete('/prizes/{prize_id}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_prize_endpoint(
|
||||
prize_id: int,
|
||||
admin: User = Depends(require_permission('wheel:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Удалить приз."""
|
||||
@@ -304,7 +304,7 @@ async def delete_prize_endpoint(
|
||||
@router.post('/prizes/reorder', status_code=status.HTTP_200_OK)
|
||||
async def reorder_prizes(
|
||||
request: ReorderPrizesRequest,
|
||||
admin: User = Depends(require_permission('wheel:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Переупорядочить призы."""
|
||||
@@ -317,7 +317,7 @@ async def reorder_prizes(
|
||||
async def get_statistics(
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
admin: User = Depends(require_permission('wheel:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Получить статистику колеса."""
|
||||
@@ -344,7 +344,7 @@ async def get_all_spins_endpoint(
|
||||
date_to: datetime | None = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(50, ge=1, le=100),
|
||||
admin: User = Depends(require_permission('wheel:read')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Получить все спины с фильтрами."""
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
"""Admin routes for managing withdrawal requests in cabinet."""
|
||||
|
||||
import json
|
||||
from typing import Literal
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import (
|
||||
ReferralEarning,
|
||||
User,
|
||||
WithdrawalRequest,
|
||||
WithdrawalRequestStatus,
|
||||
)
|
||||
from app.services.referral_withdrawal_service import referral_withdrawal_service
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.withdrawals import (
|
||||
AdminApproveWithdrawalRequest,
|
||||
AdminRejectWithdrawalRequest,
|
||||
AdminWithdrawalDetailResponse,
|
||||
AdminWithdrawalItem,
|
||||
AdminWithdrawalListResponse,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/withdrawals', tags=['Cabinet Admin Withdrawals'])
|
||||
|
||||
|
||||
def _get_risk_level(risk_score: int) -> str:
|
||||
"""Get risk level from score."""
|
||||
if risk_score >= 70:
|
||||
return 'critical'
|
||||
if risk_score >= 50:
|
||||
return 'high'
|
||||
if risk_score >= 30:
|
||||
return 'medium'
|
||||
return 'low'
|
||||
|
||||
|
||||
@router.get('', response_model=AdminWithdrawalListResponse)
|
||||
async def list_withdrawals(
|
||||
withdrawal_status: Literal['pending', 'approved', 'rejected', 'completed', 'cancelled'] | None = Query(
|
||||
None, alias='status'
|
||||
),
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
admin: User = Depends(require_permission('withdrawals:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""List all withdrawal requests."""
|
||||
query = select(WithdrawalRequest)
|
||||
count_query = select(func.count()).select_from(WithdrawalRequest)
|
||||
|
||||
if withdrawal_status:
|
||||
query = query.where(WithdrawalRequest.status == withdrawal_status)
|
||||
count_query = count_query.where(WithdrawalRequest.status == withdrawal_status)
|
||||
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Pending stats
|
||||
pending_count_result = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(WithdrawalRequest)
|
||||
.where(WithdrawalRequest.status == WithdrawalRequestStatus.PENDING.value)
|
||||
)
|
||||
pending_count = pending_count_result.scalar() or 0
|
||||
|
||||
pending_total_result = await db.execute(
|
||||
select(func.coalesce(func.sum(WithdrawalRequest.amount_kopeks), 0)).where(
|
||||
WithdrawalRequest.status == WithdrawalRequestStatus.PENDING.value
|
||||
)
|
||||
)
|
||||
pending_total = pending_total_result.scalar() or 0
|
||||
|
||||
query = query.order_by(desc(WithdrawalRequest.created_at)).offset(offset).limit(limit)
|
||||
result = await db.execute(query)
|
||||
withdrawals = result.scalars().all()
|
||||
|
||||
# Batch-fetch users to avoid N+1
|
||||
user_ids = list({w.user_id for w in withdrawals})
|
||||
if user_ids:
|
||||
users_result = await db.execute(select(User).where(User.id.in_(user_ids)))
|
||||
users_map = {u.id: u for u in users_result.scalars().all()}
|
||||
else:
|
||||
users_map = {}
|
||||
|
||||
items = []
|
||||
for w in withdrawals:
|
||||
user = users_map.get(w.user_id)
|
||||
items.append(
|
||||
AdminWithdrawalItem(
|
||||
id=w.id,
|
||||
user_id=w.user_id,
|
||||
username=user.username if user else None,
|
||||
first_name=user.first_name if user else None,
|
||||
telegram_id=user.telegram_id if user else None,
|
||||
amount_kopeks=w.amount_kopeks,
|
||||
amount_rubles=w.amount_kopeks / 100,
|
||||
status=w.status,
|
||||
risk_score=w.risk_score or 0,
|
||||
risk_level=_get_risk_level(w.risk_score or 0),
|
||||
payment_details=w.payment_details,
|
||||
admin_comment=w.admin_comment,
|
||||
created_at=w.created_at,
|
||||
processed_at=w.processed_at,
|
||||
)
|
||||
)
|
||||
|
||||
return AdminWithdrawalListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
pending_count=pending_count,
|
||||
pending_total_kopeks=pending_total,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{withdrawal_id}', response_model=AdminWithdrawalDetailResponse)
|
||||
async def get_withdrawal_detail(
|
||||
withdrawal_id: int,
|
||||
admin: User = Depends(require_permission('withdrawals:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get detailed withdrawal request with risk analysis."""
|
||||
withdrawal = await db.get(WithdrawalRequest, withdrawal_id)
|
||||
if not withdrawal:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Заявка не найдена',
|
||||
)
|
||||
|
||||
user = await db.get(User, withdrawal.user_id)
|
||||
|
||||
# Parse risk analysis
|
||||
risk_analysis = None
|
||||
if withdrawal.risk_analysis:
|
||||
try:
|
||||
risk_analysis = json.loads(withdrawal.risk_analysis)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Get referral stats
|
||||
referral_count = await db.execute(
|
||||
select(func.count()).select_from(User).where(User.referred_by_id == withdrawal.user_id)
|
||||
)
|
||||
total_earnings = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(
|
||||
ReferralEarning.user_id == withdrawal.user_id
|
||||
)
|
||||
)
|
||||
|
||||
return AdminWithdrawalDetailResponse(
|
||||
id=withdrawal.id,
|
||||
user_id=withdrawal.user_id,
|
||||
username=user.username if user else None,
|
||||
first_name=user.first_name if user else None,
|
||||
telegram_id=user.telegram_id if user else None,
|
||||
amount_kopeks=withdrawal.amount_kopeks,
|
||||
amount_rubles=withdrawal.amount_kopeks / 100,
|
||||
status=withdrawal.status,
|
||||
risk_score=withdrawal.risk_score or 0,
|
||||
risk_level=_get_risk_level(withdrawal.risk_score or 0),
|
||||
risk_analysis=risk_analysis,
|
||||
payment_details=withdrawal.payment_details,
|
||||
admin_comment=withdrawal.admin_comment,
|
||||
balance_kopeks=user.balance_kopeks if user else 0,
|
||||
total_referrals=referral_count.scalar() or 0,
|
||||
total_earnings_kopeks=total_earnings.scalar() or 0,
|
||||
created_at=withdrawal.created_at,
|
||||
processed_at=withdrawal.processed_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{withdrawal_id}/approve')
|
||||
async def approve_withdrawal(
|
||||
withdrawal_id: int,
|
||||
request: AdminApproveWithdrawalRequest,
|
||||
admin: User = Depends(require_permission('withdrawals:approve')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Approve a withdrawal request."""
|
||||
success, error = await referral_withdrawal_service.approve_request(
|
||||
db,
|
||||
request_id=withdrawal_id,
|
||||
admin_id=admin.id,
|
||||
comment=request.comment,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error,
|
||||
)
|
||||
|
||||
# Notify user about approval
|
||||
try:
|
||||
from app.bot_factory import create_bot
|
||||
from app.config import settings
|
||||
from app.services.notification_delivery_service import notification_delivery_service
|
||||
|
||||
if settings.BOT_TOKEN:
|
||||
withdrawal = await db.get(WithdrawalRequest, withdrawal_id)
|
||||
user = await db.get(User, withdrawal.user_id) if withdrawal else None
|
||||
if user and withdrawal:
|
||||
formatted_amount = settings.format_price(withdrawal.amount_kopeks)
|
||||
comment_text = f'\n{request.comment}' if request.comment else ''
|
||||
tg_message = f'✅ Ваш запрос на вывод {formatted_amount} одобрен.{comment_text}'
|
||||
bot = create_bot()
|
||||
try:
|
||||
await notification_delivery_service.notify_withdrawal_approved(
|
||||
user=user,
|
||||
amount_kopeks=withdrawal.amount_kopeks,
|
||||
comment=request.comment,
|
||||
bot=bot,
|
||||
telegram_message=tg_message,
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error('Failed to send withdrawal approval notification', error=e)
|
||||
|
||||
return {'success': True}
|
||||
|
||||
|
||||
@router.post('/{withdrawal_id}/reject')
|
||||
async def reject_withdrawal(
|
||||
withdrawal_id: int,
|
||||
request: AdminRejectWithdrawalRequest,
|
||||
admin: User = Depends(require_permission('withdrawals:reject')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reject a withdrawal request."""
|
||||
success, error = await referral_withdrawal_service.reject_request(
|
||||
db,
|
||||
request_id=withdrawal_id,
|
||||
admin_id=admin.id,
|
||||
comment=request.comment,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error or 'Не удалось отклонить заявку',
|
||||
)
|
||||
|
||||
# Notify user about rejection
|
||||
try:
|
||||
from app.bot_factory import create_bot
|
||||
from app.config import settings
|
||||
from app.services.notification_delivery_service import notification_delivery_service
|
||||
|
||||
if settings.BOT_TOKEN:
|
||||
withdrawal = await db.get(WithdrawalRequest, withdrawal_id)
|
||||
user = await db.get(User, withdrawal.user_id) if withdrawal else None
|
||||
if user and withdrawal:
|
||||
formatted_amount = settings.format_price(withdrawal.amount_kopeks)
|
||||
comment_text = f'\nПричина: {request.comment}' if request.comment else ''
|
||||
tg_message = f'❌ Ваш запрос на вывод {formatted_amount} отклонён.{comment_text}'
|
||||
bot = create_bot()
|
||||
try:
|
||||
await notification_delivery_service.notify_withdrawal_rejected(
|
||||
user=user,
|
||||
amount_kopeks=withdrawal.amount_kopeks,
|
||||
comment=request.comment,
|
||||
bot=bot,
|
||||
telegram_message=tg_message,
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error('Failed to send withdrawal rejection notification', error=e)
|
||||
|
||||
return {'success': True}
|
||||
|
||||
|
||||
@router.post('/{withdrawal_id}/complete')
|
||||
async def complete_withdrawal(
|
||||
withdrawal_id: int,
|
||||
admin: User = Depends(require_permission('withdrawals:approve')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Mark a withdrawal as completed (money transferred)."""
|
||||
success, error = await referral_withdrawal_service.complete_request(
|
||||
db,
|
||||
request_id=withdrawal_id,
|
||||
admin_id=admin.id,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error or 'Не удалось завершить заявку',
|
||||
)
|
||||
|
||||
return {'success': True}
|
||||
@@ -1,267 +0,0 @@
|
||||
"""Apple In-App Purchase cabinet route."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.apple_iap import (
|
||||
create_apple_transaction,
|
||||
)
|
||||
from app.database.crud.transaction import create_transaction as create_trans
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
from app.database.models import PaymentMethod, TransactionType, User
|
||||
from app.external.apple_iap import AppleIAPService
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.apple_iap import ApplePurchaseRequest, ApplePurchaseResponse
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(tags=['Cabinet Apple IAP'])
|
||||
|
||||
|
||||
def get_apple_iap_service() -> AppleIAPService:
|
||||
return AppleIAPService()
|
||||
|
||||
|
||||
@router.post('/apple-purchase', response_model=ApplePurchaseResponse)
|
||||
async def apple_purchase(
|
||||
request: ApplePurchaseRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
apple_iap_service: AppleIAPService = Depends(get_apple_iap_service),
|
||||
):
|
||||
"""Verify an Apple In-App Purchase and credit the user's balance.
|
||||
|
||||
The iOS app calls this endpoint after a successful StoreKit transaction.
|
||||
If the backend returns success=false, the iOS app will NOT finish the
|
||||
transaction and will retry on next launch.
|
||||
"""
|
||||
if not settings.is_apple_iap_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Apple In-App Purchase is not enabled',
|
||||
)
|
||||
|
||||
# Validate product ID
|
||||
products = settings.get_apple_iap_products()
|
||||
if request.product_id not in products:
|
||||
logger.warning(
|
||||
'Unknown Apple product ID',
|
||||
product_id=request.product_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Unknown product ID',
|
||||
)
|
||||
|
||||
amount_kopeks = products[request.product_id]
|
||||
|
||||
# Verify transaction with Apple Server API (no DB lock needed).
|
||||
# verify_transaction automatically falls back Sandbox<->Production.
|
||||
txn_info = await apple_iap_service.verify_transaction(request.transaction_id, settings.APPLE_IAP_ENVIRONMENT)
|
||||
if not txn_info:
|
||||
logger.warning(
|
||||
'Apple transaction verification failed',
|
||||
transaction_id=request.transaction_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
return ApplePurchaseResponse(success=False)
|
||||
|
||||
# Validate transaction fields
|
||||
validation_error = apple_iap_service.validate_transaction_info(txn_info, request.product_id)
|
||||
if validation_error:
|
||||
logger.warning(
|
||||
'Apple transaction validation failed',
|
||||
error=validation_error,
|
||||
transaction_id=request.transaction_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
return ApplePurchaseResponse(success=False)
|
||||
|
||||
# FIX 4: appAccountToken is mandatory -- reject if missing
|
||||
app_account_token = txn_info.get('appAccountToken')
|
||||
if not app_account_token:
|
||||
logger.warning(
|
||||
'Apple appAccountToken missing -- rejecting transaction',
|
||||
transaction_id=request.transaction_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
return ApplePurchaseResponse(success=False)
|
||||
|
||||
if app_account_token != str(user.id):
|
||||
logger.warning(
|
||||
'Apple appAccountToken mismatch -- possible replay',
|
||||
expected=str(user.id),
|
||||
received=app_account_token,
|
||||
transaction_id=request.transaction_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
return ApplePurchaseResponse(success=False)
|
||||
|
||||
# Detect sandbox transactions -- store actual environment from Apple's response
|
||||
actual_environment = txn_info.get('environment', settings.APPLE_IAP_ENVIRONMENT)
|
||||
is_sandbox = actual_environment == 'Sandbox'
|
||||
|
||||
if is_sandbox and settings.APPLE_IAP_ENVIRONMENT == 'Production':
|
||||
# Sandbox transaction on a production server (e.g. App Review).
|
||||
# Record it for audit but do NOT credit real balance.
|
||||
logger.info(
|
||||
'Apple sandbox transaction on production -- storing without balance credit',
|
||||
transaction_id=request.transaction_id,
|
||||
product_id=request.product_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
await create_apple_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
transaction_id=request.transaction_id,
|
||||
original_transaction_id=txn_info.get('originalTransactionId'),
|
||||
product_id=request.product_id,
|
||||
bundle_id=txn_info.get('bundleId', settings.APPLE_IAP_BUNDLE_ID),
|
||||
amount_kopeks=amount_kopeks,
|
||||
environment='Sandbox',
|
||||
)
|
||||
except IntegrityError:
|
||||
pass # already stored
|
||||
await db.commit()
|
||||
return ApplePurchaseResponse(success=True)
|
||||
|
||||
# Atomically insert transaction record -- unique constraint on transaction_id
|
||||
# prevents double-spend even under concurrent requests.
|
||||
apple_txn = None
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
apple_txn = await create_apple_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
transaction_id=request.transaction_id,
|
||||
original_transaction_id=txn_info.get('originalTransactionId'),
|
||||
product_id=request.product_id,
|
||||
bundle_id=txn_info.get('bundleId', settings.APPLE_IAP_BUNDLE_ID),
|
||||
amount_kopeks=amount_kopeks,
|
||||
environment=actual_environment,
|
||||
)
|
||||
except IntegrityError:
|
||||
logger.info(
|
||||
'Apple transaction already processed (idempotent)',
|
||||
transaction_id=request.transaction_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
return ApplePurchaseResponse(success=True)
|
||||
|
||||
# Create financial transaction record
|
||||
transaction = await create_trans(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=f'Пополнение через Apple IAP: {request.product_id}',
|
||||
payment_method=PaymentMethod.APPLE_IAP,
|
||||
external_id=request.transaction_id,
|
||||
is_completed=True,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
# FIX 9: Link AppleTransaction to financial Transaction via FK
|
||||
if apple_txn and transaction:
|
||||
apple_txn.transaction_id_fk = transaction.id
|
||||
apple_txn.updated_at = datetime.now(UTC)
|
||||
|
||||
# Lock user row and credit balance
|
||||
user = await lock_user_for_update(db, user)
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += amount_kopeks
|
||||
# FIX 10: Update user.updated_at when modifying balance
|
||||
user.updated_at = datetime.now(UTC)
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, 'subscription', None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = 'Первое пополнение' if was_first_topup else 'Пополнение'
|
||||
|
||||
await db.commit()
|
||||
|
||||
# --- Post-payment side-effects (after atomic commit) ---
|
||||
|
||||
from app.database.crud.transaction import emit_transaction_side_effects
|
||||
|
||||
try:
|
||||
await emit_transaction_side_effects(
|
||||
db,
|
||||
transaction,
|
||||
amount_kopeks=amount_kopeks,
|
||||
user_id=user.id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
payment_method=PaymentMethod.APPLE_IAP,
|
||||
external_id=request.transaction_id,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка emit_transaction_side_effects Apple IAP', error=error)
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(db, user.id, amount_kopeks, bot=None)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка обработки реферального пополнения Apple IAP', error=error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
# Admin notification + cart auto-purchase
|
||||
try:
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
bot = create_bot()
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки админ уведомления Apple IAP', error=error)
|
||||
|
||||
try:
|
||||
from app.services.payment.common import send_cart_notification_after_topup
|
||||
|
||||
await send_cart_notification_after_topup(user, amount_kopeks, db, bot)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка при работе с сохраненной корзиной Apple IAP', user_id=user.id, error=error)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as error:
|
||||
logger.error('Ошибка создания бота для уведомлений Apple IAP', error=error)
|
||||
|
||||
logger.info(
|
||||
'Apple IAP purchase credited',
|
||||
transaction_id=request.transaction_id,
|
||||
product_id=request.product_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
return ApplePurchaseResponse(success=True)
|
||||
+180
-1017
File diff suppressed because it is too large
Load Diff
+60
-575
@@ -4,17 +4,13 @@ import math
|
||||
import time
|
||||
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.config import settings
|
||||
from app.database.crud.saved_payment_method import (
|
||||
deactivate_payment_method,
|
||||
get_active_payment_methods_by_user,
|
||||
)
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.models import PaymentMethod, Transaction, User
|
||||
from app.services.payment_method_config_service import get_enabled_methods_for_user
|
||||
@@ -36,8 +32,6 @@ from ..schemas.balance import (
|
||||
PaymentMethodResponse,
|
||||
PendingPaymentListResponse,
|
||||
PendingPaymentResponse,
|
||||
SavedCardResponse,
|
||||
SavedCardsListResponse,
|
||||
StarsInvoiceRequest,
|
||||
StarsInvoiceResponse,
|
||||
TopUpRequest,
|
||||
@@ -105,8 +99,8 @@ async def get_transactions(
|
||||
for t in transactions:
|
||||
# Determine sign based on transaction type
|
||||
# Credits (positive): DEPOSIT, REFERRAL_REWARD, REFUND, POLL_REWARD
|
||||
# Debits (negative): SUBSCRIPTION_PAYMENT, WITHDRAWAL, GIFT_PAYMENT
|
||||
is_debit = t.type in ['subscription_payment', 'withdrawal', 'gift_payment']
|
||||
# Debits (negative): SUBSCRIPTION_PAYMENT, WITHDRAWAL
|
||||
is_debit = t.type in ['subscription_payment', 'withdrawal']
|
||||
amount_kopeks = -abs(t.amount_kopeks) if is_debit else abs(t.amount_kopeks)
|
||||
|
||||
items.append(
|
||||
@@ -201,7 +195,7 @@ async def get_payment_methods(
|
||||
'description': description,
|
||||
}
|
||||
)
|
||||
options = formatted_options or None
|
||||
options = formatted_options if formatted_options else None
|
||||
|
||||
methods.append(
|
||||
PaymentMethodResponse(
|
||||
@@ -247,16 +241,13 @@ async def create_stars_invoice(
|
||||
detail='Maximum amount is 10,000.00 RUB',
|
||||
)
|
||||
|
||||
# Calculate Stars amount and normalize kopeks to match exact star value
|
||||
# Calculate Stars amount
|
||||
try:
|
||||
amount_rubles = request.amount_kopeks / 100
|
||||
stars_amount = settings.rubles_to_stars(amount_rubles)
|
||||
|
||||
if stars_amount <= 0:
|
||||
stars_amount = 1
|
||||
|
||||
# Normalize kopeks so credited amount = stars * rate (no rounding mismatch)
|
||||
normalized_kopeks = round(stars_amount * settings.get_stars_rate() * 100)
|
||||
except Exception as e:
|
||||
logger.error('Error calculating Stars amount', error=e)
|
||||
raise HTTPException(
|
||||
@@ -265,41 +256,54 @@ async def create_stars_invoice(
|
||||
)
|
||||
|
||||
# Create payload for tracking payment
|
||||
payload = f'balance_topup_{user.id}_{normalized_kopeks}_{int(time.time())}'
|
||||
payload = f'balance_topup_{user.id}_{request.amount_kopeks}_{int(time.time())}'
|
||||
|
||||
# Create invoice through Telegram Bot API
|
||||
try:
|
||||
from aiogram.exceptions import TelegramAPIError
|
||||
from aiogram.types import LabeledPrice
|
||||
bot_token = settings.BOT_TOKEN
|
||||
api_url = f'https://api.telegram.org/bot{bot_token}/createInvoiceLink'
|
||||
|
||||
async with create_bot() as bot:
|
||||
invoice_url = await bot.create_invoice_link(
|
||||
title='Пополнение баланса VPN',
|
||||
description=f'Пополнение баланса на {normalized_kopeks / 100:.2f} ₽ ({stars_amount} ⭐)',
|
||||
payload=payload,
|
||||
provider_token='',
|
||||
currency='XTR',
|
||||
prices=[LabeledPrice(label='Пополнение баланса', amount=stars_amount)],
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
api_url,
|
||||
json={
|
||||
'title': 'Пополнение баланса VPN',
|
||||
'description': f'Пополнение баланса на {amount_rubles:.2f} ₽ ({stars_amount} ⭐)',
|
||||
'payload': payload,
|
||||
'provider_token': '', # Empty for Stars
|
||||
'currency': 'XTR',
|
||||
'prices': [{'label': 'Пополнение баланса', 'amount': stars_amount}],
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Created Stars invoice for balance top-up: user=, amount= kopeks, stars',
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
stars_amount=stars_amount,
|
||||
)
|
||||
result = response.json()
|
||||
|
||||
return StarsInvoiceResponse(
|
||||
invoice_url=invoice_url,
|
||||
stars_amount=stars_amount,
|
||||
amount_kopeks=normalized_kopeks,
|
||||
)
|
||||
if not result.get('ok'):
|
||||
logger.error('Telegram API error', result=result)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create Stars invoice',
|
||||
)
|
||||
|
||||
except TelegramAPIError as e:
|
||||
logger.error('Error creating Stars invoice', error=e)
|
||||
invoice_url = result['result']
|
||||
logger.info(
|
||||
'Created Stars invoice for balance top-up: user=, amount= kopeks, stars',
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
stars_amount=stars_amount,
|
||||
)
|
||||
|
||||
return StarsInvoiceResponse(
|
||||
invoice_url=invoice_url,
|
||||
stars_amount=stars_amount,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error('HTTP error creating Stars invoice', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create Stars invoice',
|
||||
detail='Failed to connect to Telegram API',
|
||||
)
|
||||
|
||||
|
||||
@@ -310,12 +314,6 @@ async def create_topup(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Create payment for balance top-up."""
|
||||
if getattr(user, 'restriction_topup', False):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Balance top-up is restricted for this account',
|
||||
)
|
||||
|
||||
# Validate payment method
|
||||
methods = await get_payment_methods(user=user, db=db)
|
||||
method = next((m for m in methods if m.id == request.payment_method), None)
|
||||
@@ -342,9 +340,6 @@ async def create_topup(
|
||||
amount_rubles = request.amount_kopeks / 100
|
||||
payment_url = None
|
||||
payment_id = None
|
||||
cabinet_return_url = f'{settings.CABINET_URL.rstrip("/")}/balance/top-up/result?method={request.payment_method}'
|
||||
cabinet_success_url = f'{cabinet_return_url}&status=success'
|
||||
cabinet_failed_url = f'{cabinet_return_url}&status=failed'
|
||||
|
||||
try:
|
||||
if request.payment_method == 'yookassa':
|
||||
@@ -360,7 +355,7 @@ async def create_topup(
|
||||
option = (request.payment_option or '').strip().lower()
|
||||
# Use description with telegram_id for tax receipts
|
||||
description = settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id
|
||||
)
|
||||
if option == 'sbp':
|
||||
result = await payment_service.create_yookassa_sbp_payment(
|
||||
@@ -369,7 +364,6 @@ async def create_topup(
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=description,
|
||||
metadata=yookassa_metadata,
|
||||
return_url=cabinet_return_url,
|
||||
)
|
||||
else:
|
||||
result = await payment_service.create_yookassa_payment(
|
||||
@@ -378,12 +372,11 @@ async def create_topup(
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=description,
|
||||
metadata=yookassa_metadata,
|
||||
return_url=cabinet_return_url,
|
||||
)
|
||||
|
||||
if result:
|
||||
payment_url = result.get('confirmation_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('yookassa_payment_id') or 'pending')
|
||||
payment_id = result.get('yookassa_payment_id')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -423,7 +416,7 @@ async def create_topup(
|
||||
amount_usd=amount_usd,
|
||||
asset=settings.CRYPTOBOT_DEFAULT_ASSET,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id
|
||||
),
|
||||
payload=f'cabinet_topup_{user.id}_{request.amount_kopeks}',
|
||||
)
|
||||
@@ -484,12 +477,10 @@ async def create_topup(
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id
|
||||
),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_method_code=method_code,
|
||||
return_url=cabinet_success_url,
|
||||
failed_url=cabinet_failed_url,
|
||||
)
|
||||
|
||||
if result and result.get('redirect_url'):
|
||||
@@ -513,12 +504,8 @@ async def create_topup(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
return_url=cabinet_return_url,
|
||||
success_url=cabinet_success_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
@@ -542,9 +529,7 @@ async def create_topup(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
)
|
||||
|
||||
@@ -568,17 +553,16 @@ async def create_topup(
|
||||
option = (request.payment_option or '').strip().lower()
|
||||
if option not in {'card', 'sbp'}:
|
||||
option = 'sbp'
|
||||
provider_method = 'card' if option == 'card' else 'sbp'
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_pal24_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_method=option,
|
||||
payment_method=provider_method,
|
||||
)
|
||||
|
||||
if result:
|
||||
@@ -617,12 +601,8 @@ async def create_topup(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
return_url=cabinet_success_url,
|
||||
failed_url=cabinet_failed_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
@@ -646,13 +626,9 @@ async def create_topup(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
telegram_id=user.telegram_id,
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
return_url=cabinet_success_url,
|
||||
failed_url=cabinet_failed_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
@@ -676,9 +652,7 @@ async def create_topup(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
)
|
||||
|
||||
@@ -698,22 +672,14 @@ async def create_topup(
|
||||
detail='KassaAI payment method is unavailable',
|
||||
)
|
||||
|
||||
# Use payment_option to select sbp or card
|
||||
KASSA_AI_OPTION_MAP = {'sbp': 44, 'card': 36, 'sberpay': 43}
|
||||
option = (request.payment_option or '').strip().lower()
|
||||
ps_id = KASSA_AI_OPTION_MAP.get(option) # None = use env default
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_kassa_ai_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_system_id=ps_id,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
@@ -725,35 +691,6 @@ async def create_topup(
|
||||
detail='Failed to create KassaAI payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'riopay':
|
||||
if not settings.is_riopay_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='RioPay payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_riopay_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
success_url=cabinet_success_url,
|
||||
fail_url=cabinet_failed_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('riopay_order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create RioPay payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'tribute':
|
||||
if not settings.TRIBUTE_ENABLED or not settings.TRIBUTE_DONATE_LINK:
|
||||
raise HTTPException(
|
||||
@@ -765,248 +702,6 @@ async def create_topup(
|
||||
payment_url = f'{settings.TRIBUTE_DONATE_LINK}&user_id={user_identifier}'
|
||||
payment_id = f'tribute_{user_identifier}_{request.amount_kopeks}'
|
||||
|
||||
elif request.payment_method == 'severpay':
|
||||
if not settings.is_severpay_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='SeverPay payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_severpay_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
return_url=cabinet_success_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create SeverPay payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'paypear':
|
||||
if not settings.is_paypear_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='PayPear payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_paypear_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
return_url=cabinet_success_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create PayPear payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'rollypay':
|
||||
if not settings.is_rollypay_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='RollyPay payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
payment_method_type = request.payment_option or None
|
||||
result = await payment_service.create_rollypay_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_method_type=payment_method_type,
|
||||
return_url=cabinet_success_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create RollyPay payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'overpay':
|
||||
if not settings.is_overpay_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Overpay payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_overpay_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
return_url=cabinet_success_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create Overpay payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'aurapay':
|
||||
if not settings.is_aurapay_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='AuraPay payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
payment_method_type = request.payment_option or None
|
||||
result = await payment_service.create_aurapay_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_method_type=payment_method_type,
|
||||
return_url=cabinet_success_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create AuraPay payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'jupiter':
|
||||
if not settings.is_jupiter_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Jupiter payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
payment_method_type = request.payment_option or None
|
||||
result = await payment_service.create_jupiter_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_method_type=payment_method_type,
|
||||
return_url=cabinet_success_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create Jupiter payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'donut':
|
||||
if not settings.is_donut_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Donut payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
payment_method_type = request.payment_option or None
|
||||
result = await payment_service.create_donut_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_method_type=payment_method_type,
|
||||
return_url=cabinet_success_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create Donut payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'lava':
|
||||
if not settings.is_lava_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Lava payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
payment_method_type = request.payment_option or None
|
||||
result = await payment_service.create_lava_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_method_type=payment_method_type,
|
||||
return_url=cabinet_success_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create Lava payment',
|
||||
)
|
||||
|
||||
else:
|
||||
# For other payment methods, redirect to bot
|
||||
raise HTTPException(
|
||||
@@ -1147,56 +842,6 @@ def _get_status_info(record: PendingPayment) -> tuple[str, str]:
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.RIOPAY:
|
||||
mapping = {
|
||||
'pending': ('⏳', 'Ожидает оплаты'),
|
||||
'success': ('✅', 'Оплачено'),
|
||||
'failed': ('❌', 'Ошибка'),
|
||||
'canceled': ('❌', 'Отменено'),
|
||||
'expired': ('⌛', 'Истёк'),
|
||||
'amount_mismatch': ('⚠️', 'Несовпадение суммы'),
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.JUPITER:
|
||||
mapping = {
|
||||
'pending': ('⏳', 'Ожидает оплаты'),
|
||||
'processing': ('⌛', 'Обрабатывается'),
|
||||
'success': ('✅', 'Оплачено'),
|
||||
'cancelled': ('❌', 'Отменено'),
|
||||
'declined': ('❌', 'Отклонено'),
|
||||
'error': ('❌', 'Ошибка'),
|
||||
'amount_mismatch': ('⚠️', 'Несовпадение суммы'),
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.DONUT:
|
||||
mapping = {
|
||||
'pending': ('⏳', 'Ожидает оплаты'),
|
||||
'created': ('⏳', 'Создано'),
|
||||
'processing': ('⌛', 'Обрабатывается'),
|
||||
'success': ('✅', 'Оплачено'),
|
||||
'cancelled': ('❌', 'Отменено'),
|
||||
'error': ('❌', 'Ошибка'),
|
||||
'amount_mismatch': ('⚠️', 'Несовпадение суммы'),
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.LAVA:
|
||||
mapping = {
|
||||
'pending': ('⏳', 'Ожидает оплаты'),
|
||||
'created': ('⏳', 'Создано'),
|
||||
'processing': ('⌛', 'Обрабатывается'),
|
||||
'success': ('✅', 'Оплачено'),
|
||||
'cancel': ('❌', 'Отменено'),
|
||||
'cancelled': ('❌', 'Отменено'),
|
||||
'expired': ('⌛', 'Истёк'),
|
||||
'failed': ('❌', 'Ошибка'),
|
||||
'error': ('❌', 'Ошибка'),
|
||||
'amount_mismatch': ('⚠️', 'Несовпадение суммы'),
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
return '❓', 'Неизвестно'
|
||||
|
||||
|
||||
@@ -1220,15 +865,13 @@ def _is_checkable(record: PendingPayment) -> bool:
|
||||
if record.method == PaymentMethod.YOOKASSA:
|
||||
return status in {'pending', 'waiting_for_capture'}
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
return status == 'active'
|
||||
return status in {'active'}
|
||||
if record.method == PaymentMethod.CLOUDPAYMENTS:
|
||||
return status in {'pending', 'authorized'}
|
||||
if record.method == PaymentMethod.FREEKASSA:
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
if record.method == PaymentMethod.KASSA_AI:
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
if record.method == PaymentMethod.RIOPAY:
|
||||
return status in {'pending'}
|
||||
return False
|
||||
|
||||
|
||||
@@ -1252,12 +895,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 in (
|
||||
PaymentMethod.CLOUDPAYMENTS,
|
||||
PaymentMethod.FREEKASSA,
|
||||
PaymentMethod.KASSA_AI,
|
||||
PaymentMethod.RIOPAY,
|
||||
):
|
||||
elif record.method in (PaymentMethod.CLOUDPAYMENTS, PaymentMethod.FREEKASSA, PaymentMethod.KASSA_AI):
|
||||
payment_url = getattr(payment, 'payment_url', None) or payment_url
|
||||
|
||||
return payment_url
|
||||
@@ -1318,103 +956,6 @@ async def get_pending_payments(
|
||||
)
|
||||
|
||||
|
||||
@router.get('/pending-payments/{method}/latest', response_model=PendingPaymentResponse)
|
||||
async def get_latest_payment_by_method(
|
||||
method: str,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get user's most recent payment for a given method (any status, not just pending)."""
|
||||
try:
|
||||
payment_method = PaymentMethod(method)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid payment method: {method}',
|
||||
)
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import (
|
||||
AuraPayPayment,
|
||||
CloudPaymentsPayment,
|
||||
CryptoBotPayment,
|
||||
FreekassaPayment,
|
||||
HeleketPayment,
|
||||
KassaAiPayment,
|
||||
MulenPayPayment,
|
||||
OverpayPayment,
|
||||
Pal24Payment,
|
||||
PayPearPayment,
|
||||
PlategaPayment,
|
||||
RioPayPayment,
|
||||
RollyPayPayment,
|
||||
SeverPayPayment,
|
||||
WataPayment,
|
||||
YooKassaPayment,
|
||||
)
|
||||
|
||||
model_map: dict[PaymentMethod, type] = {
|
||||
PaymentMethod.YOOKASSA: YooKassaPayment,
|
||||
PaymentMethod.CRYPTOBOT: CryptoBotPayment,
|
||||
PaymentMethod.HELEKET: HeleketPayment,
|
||||
PaymentMethod.MULENPAY: MulenPayPayment,
|
||||
PaymentMethod.PAL24: Pal24Payment,
|
||||
PaymentMethod.WATA: WataPayment,
|
||||
PaymentMethod.PLATEGA: PlategaPayment,
|
||||
PaymentMethod.CLOUDPAYMENTS: CloudPaymentsPayment,
|
||||
PaymentMethod.FREEKASSA: FreekassaPayment,
|
||||
PaymentMethod.KASSA_AI: KassaAiPayment,
|
||||
PaymentMethod.RIOPAY: RioPayPayment,
|
||||
PaymentMethod.SEVERPAY: SeverPayPayment,
|
||||
PaymentMethod.ROLLYPAY: RollyPayPayment,
|
||||
PaymentMethod.PAYPEAR: PayPearPayment,
|
||||
PaymentMethod.OVERPAY: OverpayPayment,
|
||||
PaymentMethod.AURAPAY: AuraPayPayment,
|
||||
}
|
||||
|
||||
model = model_map.get(payment_method)
|
||||
if not model:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Unsupported payment method: {method}',
|
||||
)
|
||||
|
||||
cutoff = datetime.now(UTC) - timedelta(hours=1)
|
||||
stmt = (
|
||||
select(model)
|
||||
.options(selectinload(model.user))
|
||||
.where(model.user_id == user.id, model.created_at >= cutoff)
|
||||
.order_by(desc(model.created_at))
|
||||
.limit(1)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
payment = result.scalars().first()
|
||||
|
||||
if not payment:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='No recent payments found',
|
||||
)
|
||||
|
||||
record = PendingPayment(
|
||||
local_id=payment.id,
|
||||
method=payment_method,
|
||||
identifier=str(getattr(payment, 'correlation_id', None) or payment.id),
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
created_at=payment.created_at,
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
user=payment.user,
|
||||
payment=payment,
|
||||
)
|
||||
|
||||
return _record_to_response(record)
|
||||
|
||||
|
||||
@router.get('/pending-payments/{method}/{payment_id}', response_model=PendingPaymentResponse)
|
||||
async def get_pending_payment_details(
|
||||
method: str,
|
||||
@@ -1494,12 +1035,8 @@ async def check_payment_status(
|
||||
old_is_paid = record.is_paid
|
||||
|
||||
# Run manual check
|
||||
bot = create_bot()
|
||||
try:
|
||||
payment_service = PaymentService(bot=bot)
|
||||
updated = await run_manual_check(db, payment_method, payment_id, payment_service)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
payment_service = PaymentService()
|
||||
updated = await run_manual_check(db, payment_method, payment_id, payment_service)
|
||||
|
||||
if not updated:
|
||||
return ManualCheckResponse(
|
||||
@@ -1525,55 +1062,3 @@ async def check_payment_status(
|
||||
old_status=old_status,
|
||||
new_status=updated.status,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/saved-cards', response_model=SavedCardsListResponse)
|
||||
async def get_saved_cards(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get user's saved payment methods (cards) for recurrent payments."""
|
||||
recurrent_enabled = settings.YOOKASSA_RECURRENT_ENABLED
|
||||
|
||||
if not recurrent_enabled:
|
||||
return SavedCardsListResponse(cards=[], recurrent_enabled=False)
|
||||
|
||||
methods = await get_active_payment_methods_by_user(db, user.id)
|
||||
|
||||
cards = [
|
||||
SavedCardResponse(
|
||||
id=m.id,
|
||||
method_type=m.method_type,
|
||||
card_last4=m.card_last4,
|
||||
card_type=m.card_type,
|
||||
title=m.title,
|
||||
created_at=m.created_at,
|
||||
)
|
||||
for m in methods
|
||||
]
|
||||
|
||||
return SavedCardsListResponse(cards=cards, recurrent_enabled=True)
|
||||
|
||||
|
||||
@router.delete('/saved-cards/{card_id}', status_code=status.HTTP_200_OK)
|
||||
async def delete_saved_card(
|
||||
card_id: int,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Unlink (deactivate) a saved payment method."""
|
||||
if not settings.YOOKASSA_RECURRENT_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Recurrent payments are not enabled',
|
||||
)
|
||||
|
||||
success = await deactivate_payment_method(db, card_id, user.id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Saved card not found',
|
||||
)
|
||||
|
||||
return {'success': True, 'message': 'Card unlinked successfully'}
|
||||
|
||||
+30
-369
@@ -1,23 +1,20 @@
|
||||
"""Branding routes for cabinet - logo, project name, and theme colors management."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.system_setting import get_setting_value
|
||||
from app.database.models import SystemSetting, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user, require_permission
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -40,24 +37,6 @@ YANDEX_METRIKA_ID_KEY = 'CABINET_YANDEX_METRIKA_ID' # Stores counter ID (numeri
|
||||
GOOGLE_ADS_ID_KEY = 'CABINET_GOOGLE_ADS_ID' # Stores conversion ID (e.g. "AW-123456789")
|
||||
GOOGLE_ADS_LABEL_KEY = 'CABINET_GOOGLE_ADS_LABEL' # Stores conversion label (alphanumeric)
|
||||
LITE_MODE_ENABLED_KEY = 'CABINET_LITE_MODE_ENABLED' # Stores "true" or "false"
|
||||
GIFT_ENABLED_KEY = 'CABINET_GIFT_ENABLED' # Stores "true" or "false"
|
||||
ANIMATION_CONFIG_KEY = 'CABINET_ANIMATION_CONFIG' # Stores JSON with animation config
|
||||
TELEGRAM_WIDGET_SIZE_KEY = 'TELEGRAM_WIDGET_SIZE'
|
||||
TELEGRAM_WIDGET_RADIUS_KEY = 'TELEGRAM_WIDGET_RADIUS'
|
||||
TELEGRAM_WIDGET_USERPIC_KEY = 'TELEGRAM_WIDGET_USERPIC'
|
||||
TELEGRAM_WIDGET_REQUEST_ACCESS_KEY = 'TELEGRAM_WIDGET_REQUEST_ACCESS'
|
||||
TELEGRAM_OIDC_ENABLED_KEY = 'TELEGRAM_OIDC_ENABLED'
|
||||
TELEGRAM_OIDC_CLIENT_ID_KEY = 'TELEGRAM_OIDC_CLIENT_ID'
|
||||
|
||||
# Default animation config
|
||||
DEFAULT_ANIMATION_CONFIG = {
|
||||
'enabled': True,
|
||||
'type': 'aurora',
|
||||
'settings': {},
|
||||
'opacity': 1.0,
|
||||
'blur': 0,
|
||||
'reducedOnMobile': True,
|
||||
}
|
||||
|
||||
# Allowed image types
|
||||
ALLOWED_CONTENT_TYPES = {'image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'image/svg+xml'}
|
||||
@@ -142,92 +121,6 @@ class AnimationEnabledUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
ALLOWED_BG_TYPES = (
|
||||
'aurora',
|
||||
'sparkles',
|
||||
'vortex',
|
||||
'shooting-stars',
|
||||
'background-beams',
|
||||
'background-beams-collision',
|
||||
'gradient-animation',
|
||||
'wavy',
|
||||
'background-lines',
|
||||
'boxes',
|
||||
'meteors',
|
||||
'grid',
|
||||
'dots',
|
||||
'spotlight',
|
||||
'ripple',
|
||||
'none',
|
||||
)
|
||||
|
||||
MAX_SETTINGS_KEYS = 20
|
||||
MAX_SETTINGS_VALUE_LEN = 200
|
||||
|
||||
|
||||
def _validate_settings(v: dict) -> dict:
|
||||
"""Validate settings dict: flat structure, bounded size, no nested objects."""
|
||||
if len(v) > MAX_SETTINGS_KEYS:
|
||||
raise ValueError(f'Settings must have at most {MAX_SETTINGS_KEYS} keys')
|
||||
for key, val in v.items():
|
||||
if not isinstance(key, str) or len(key) > 50:
|
||||
raise ValueError('Setting keys must be strings under 50 characters')
|
||||
if isinstance(val, dict | list):
|
||||
raise ValueError('Nested objects/arrays not allowed in settings')
|
||||
if isinstance(val, str) and len(val) > MAX_SETTINGS_VALUE_LEN:
|
||||
raise ValueError(f'String setting values must be under {MAX_SETTINGS_VALUE_LEN} characters')
|
||||
return v
|
||||
|
||||
|
||||
class AnimationConfigResponse(BaseModel):
|
||||
"""Full animation config."""
|
||||
|
||||
enabled: bool = True
|
||||
type: str = 'aurora'
|
||||
settings: dict = Field(default_factory=dict)
|
||||
opacity: float = Field(default=1.0, ge=0.0, le=1.0)
|
||||
blur: float = Field(default=0, ge=0, le=100)
|
||||
reducedOnMobile: bool = True
|
||||
|
||||
|
||||
class AnimationConfigUpdate(BaseModel):
|
||||
"""Request to update animation config (partial update)."""
|
||||
|
||||
enabled: bool | None = None
|
||||
type: (
|
||||
Literal[
|
||||
'aurora',
|
||||
'sparkles',
|
||||
'vortex',
|
||||
'shooting-stars',
|
||||
'background-beams',
|
||||
'background-beams-collision',
|
||||
'gradient-animation',
|
||||
'wavy',
|
||||
'background-lines',
|
||||
'boxes',
|
||||
'meteors',
|
||||
'grid',
|
||||
'dots',
|
||||
'spotlight',
|
||||
'ripple',
|
||||
'none',
|
||||
]
|
||||
| None
|
||||
) = None
|
||||
settings: dict | None = None
|
||||
opacity: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||
blur: float | None = Field(default=None, ge=0, le=100)
|
||||
reducedOnMobile: bool | None = None
|
||||
|
||||
@field_validator('settings')
|
||||
@classmethod
|
||||
def validate_settings(cls, v: dict | None) -> dict | None:
|
||||
if v is None:
|
||||
return v
|
||||
return _validate_settings(v)
|
||||
|
||||
|
||||
class FullscreenEnabledResponse(BaseModel):
|
||||
"""Fullscreen enabled setting."""
|
||||
|
||||
@@ -244,7 +137,6 @@ class EmailAuthEnabledResponse(BaseModel):
|
||||
"""Email auth enabled setting."""
|
||||
|
||||
enabled: bool = True
|
||||
verification_enabled: bool = True
|
||||
|
||||
|
||||
class EmailAuthEnabledUpdate(BaseModel):
|
||||
@@ -253,20 +145,6 @@ class EmailAuthEnabledUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class TelegramWidgetConfigResponse(BaseModel):
|
||||
"""Public Telegram Login Widget configuration."""
|
||||
|
||||
bot_username: str
|
||||
size: Literal['large', 'medium', 'small'] = 'large'
|
||||
radius: int = Field(default=8, ge=0, le=20)
|
||||
userpic: bool = True
|
||||
request_access: bool = True
|
||||
|
||||
# OIDC fields (frontend decides which flow to use)
|
||||
oidc_enabled: bool = False
|
||||
oidc_client_id: str = ''
|
||||
|
||||
|
||||
class LiteModeEnabledResponse(BaseModel):
|
||||
"""Lite mode enabled setting."""
|
||||
|
||||
@@ -279,36 +157,12 @@ class LiteModeEnabledUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class GiftEnabledResponse(BaseModel):
|
||||
"""Gift feature enabled setting."""
|
||||
|
||||
enabled: bool = False
|
||||
|
||||
|
||||
class GiftEnabledUpdate(BaseModel):
|
||||
"""Request to update gift feature setting."""
|
||||
|
||||
enabled: bool
|
||||
|
||||
|
||||
class OfflineConvGoal(BaseModel):
|
||||
"""Yandex Metrika offline conversion goal descriptor."""
|
||||
|
||||
name: str
|
||||
event_id: str
|
||||
dedup: str
|
||||
|
||||
|
||||
class AnalyticsCountersResponse(BaseModel):
|
||||
"""Analytics counter settings."""
|
||||
|
||||
yandex_metrika_id: str = ''
|
||||
google_ads_id: str = ''
|
||||
google_ads_label: str = ''
|
||||
offline_conv_enabled: bool = False
|
||||
offline_conv_counter_id: str = ''
|
||||
offline_conv_measurement_secret_masked: str = ''
|
||||
offline_conv_goals: list[OfflineConvGoal] = []
|
||||
|
||||
|
||||
class AnalyticsCountersUpdate(BaseModel):
|
||||
@@ -344,6 +198,13 @@ def ensure_branding_dir():
|
||||
BRANDING_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
async def get_setting_value(db: AsyncSession, key: str) -> str | None:
|
||||
"""Get a setting value from database."""
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
return setting.value if setting else None
|
||||
|
||||
|
||||
async def set_setting_value(db: AsyncSession, key: str, value: str):
|
||||
"""Set a setting value in database."""
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||
@@ -415,7 +276,7 @@ async def get_logo():
|
||||
"""
|
||||
logo_path = get_logo_path()
|
||||
|
||||
if logo_path is None or not await asyncio.to_thread(logo_path.exists):
|
||||
if logo_path is None or not logo_path.exists():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='No custom logo set')
|
||||
|
||||
# Determine media type from file extension
|
||||
@@ -435,7 +296,7 @@ async def get_logo():
|
||||
@router.put('/name', response_model=BrandingResponse)
|
||||
async def update_branding_name(
|
||||
payload: BrandingNameUpdate,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update the project name. Admin only. Empty name allowed (logo only mode)."""
|
||||
@@ -463,7 +324,7 @@ async def update_branding_name(
|
||||
@router.post('/logo', response_model=BrandingResponse)
|
||||
async def upload_logo(
|
||||
file: UploadFile = File(...),
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Upload a custom logo. Admin only."""
|
||||
@@ -484,7 +345,7 @@ async def upload_logo(
|
||||
)
|
||||
|
||||
# Ensure directory exists
|
||||
await asyncio.to_thread(ensure_branding_dir)
|
||||
ensure_branding_dir()
|
||||
|
||||
# Determine file extension from content type
|
||||
ext_map = {
|
||||
@@ -497,12 +358,12 @@ async def upload_logo(
|
||||
extension = ext_map.get(file.content_type, '.png')
|
||||
|
||||
# Remove old logo files with any extension
|
||||
for old_file in await asyncio.to_thread(lambda: list(BRANDING_DIR.glob('logo.*'))):
|
||||
await asyncio.to_thread(old_file.unlink)
|
||||
for old_file in BRANDING_DIR.glob('logo.*'):
|
||||
old_file.unlink()
|
||||
|
||||
# Save new logo
|
||||
logo_path = BRANDING_DIR / f'logo{extension}'
|
||||
await asyncio.to_thread(logo_path.write_bytes, content)
|
||||
logo_path.write_bytes(content)
|
||||
|
||||
# Mark that we have a custom logo
|
||||
await set_setting_value(db, BRANDING_LOGO_KEY, 'custom')
|
||||
@@ -526,13 +387,13 @@ async def upload_logo(
|
||||
|
||||
@router.delete('/logo', response_model=BrandingResponse)
|
||||
async def delete_logo(
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Delete custom logo and revert to letter. Admin only."""
|
||||
# Remove logo files
|
||||
for old_file in await asyncio.to_thread(lambda: list(BRANDING_DIR.glob('logo.*'))):
|
||||
await asyncio.to_thread(old_file.unlink)
|
||||
for old_file in BRANDING_DIR.glob('logo.*'):
|
||||
old_file.unlink()
|
||||
|
||||
# Update setting
|
||||
await set_setting_value(db, BRANDING_LOGO_KEY, 'default')
|
||||
@@ -598,7 +459,7 @@ async def get_theme_colors(
|
||||
@router.patch('/colors', response_model=ThemeColorsResponse)
|
||||
async def update_theme_colors(
|
||||
payload: ThemeColorsUpdate,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update theme colors. Admin only. Partial update supported."""
|
||||
@@ -632,7 +493,7 @@ async def update_theme_colors(
|
||||
|
||||
@router.post('/colors/reset', response_model=ThemeColorsResponse)
|
||||
async def reset_theme_colors(
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reset theme colors to defaults. Admin only."""
|
||||
@@ -672,7 +533,7 @@ async def get_enabled_themes(
|
||||
@router.patch('/themes', response_model=EnabledThemesResponse)
|
||||
async def update_enabled_themes(
|
||||
payload: EnabledThemesUpdate,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update which themes are enabled. Admin only. At least one theme must be enabled."""
|
||||
@@ -726,7 +587,7 @@ async def get_animation_enabled(
|
||||
@router.patch('/animation', response_model=AnimationEnabledResponse)
|
||||
async def update_animation_enabled(
|
||||
payload: AnimationEnabledUpdate,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update animation enabled setting. Admin only."""
|
||||
@@ -737,69 +598,6 @@ async def update_animation_enabled(
|
||||
return AnimationEnabledResponse(enabled=payload.enabled)
|
||||
|
||||
|
||||
# ============ Animation Config Routes (new JSON-based) ============
|
||||
|
||||
|
||||
@router.get('/animation-config', response_model=AnimationConfigResponse)
|
||||
async def get_animation_config(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get full animation config. Public endpoint."""
|
||||
config_value = await get_setting_value(db, ANIMATION_CONFIG_KEY)
|
||||
|
||||
if config_value is not None:
|
||||
try:
|
||||
config = json.loads(config_value)
|
||||
return AnimationConfigResponse(**config)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Auto-migrate from old ANIMATION_ENABLED_KEY
|
||||
old_value = await get_setting_value(db, ANIMATION_ENABLED_KEY)
|
||||
if old_value is not None:
|
||||
config = {**DEFAULT_ANIMATION_CONFIG, 'enabled': old_value.lower() == 'true'}
|
||||
await set_setting_value(db, ANIMATION_CONFIG_KEY, json.dumps(config))
|
||||
return AnimationConfigResponse(**config)
|
||||
|
||||
return AnimationConfigResponse(**DEFAULT_ANIMATION_CONFIG)
|
||||
|
||||
|
||||
@router.patch('/animation-config', response_model=AnimationConfigResponse)
|
||||
async def update_animation_config(
|
||||
payload: AnimationConfigUpdate,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update animation config (partial update). Admin only."""
|
||||
# Get current config
|
||||
config_value = await get_setting_value(db, ANIMATION_CONFIG_KEY)
|
||||
if config_value:
|
||||
try:
|
||||
current = json.loads(config_value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
current = dict(DEFAULT_ANIMATION_CONFIG)
|
||||
else:
|
||||
current = dict(DEFAULT_ANIMATION_CONFIG)
|
||||
|
||||
# Merge only provided fields
|
||||
update_data = payload.model_dump(exclude_none=True)
|
||||
current.update(update_data)
|
||||
|
||||
await set_setting_value(db, ANIMATION_CONFIG_KEY, json.dumps(current))
|
||||
|
||||
# Also sync old key for backwards compat
|
||||
await set_setting_value(db, ANIMATION_ENABLED_KEY, str(current.get('enabled', True)).lower())
|
||||
|
||||
logger.info(
|
||||
'Admin updated animation config',
|
||||
telegram_id=admin.telegram_id,
|
||||
type=current.get('type'),
|
||||
enabled=current.get('enabled'),
|
||||
)
|
||||
|
||||
return AnimationConfigResponse(**current)
|
||||
|
||||
|
||||
# ============ Fullscreen Routes ============
|
||||
|
||||
|
||||
@@ -824,7 +622,7 @@ async def get_fullscreen_enabled(
|
||||
@router.patch('/fullscreen', response_model=FullscreenEnabledResponse)
|
||||
async def update_fullscreen_enabled(
|
||||
payload: FullscreenEnabledUpdate,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update fullscreen enabled setting. Admin only."""
|
||||
@@ -851,22 +649,16 @@ async def get_email_auth_enabled(
|
||||
|
||||
if email_auth_value is not None:
|
||||
enabled = email_auth_value.lower() == 'true'
|
||||
return EmailAuthEnabledResponse(
|
||||
enabled=enabled,
|
||||
verification_enabled=settings.is_cabinet_email_verification_enabled(),
|
||||
)
|
||||
return EmailAuthEnabledResponse(enabled=enabled)
|
||||
|
||||
# Default: check config setting
|
||||
return EmailAuthEnabledResponse(
|
||||
enabled=settings.is_cabinet_email_auth_enabled(),
|
||||
verification_enabled=settings.is_cabinet_email_verification_enabled(),
|
||||
)
|
||||
return EmailAuthEnabledResponse(enabled=settings.is_cabinet_email_auth_enabled())
|
||||
|
||||
|
||||
@router.patch('/email-auth', response_model=EmailAuthEnabledResponse)
|
||||
async def update_email_auth_enabled(
|
||||
payload: EmailAuthEnabledUpdate,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update email auth enabled setting. Admin only."""
|
||||
@@ -874,51 +666,7 @@ async def update_email_auth_enabled(
|
||||
|
||||
logger.info('Admin set email auth enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
|
||||
|
||||
return EmailAuthEnabledResponse(
|
||||
enabled=payload.enabled,
|
||||
verification_enabled=settings.is_cabinet_email_verification_enabled(),
|
||||
)
|
||||
|
||||
|
||||
# ============ Telegram Widget Config Routes ============
|
||||
|
||||
|
||||
@router.get('/telegram-widget', response_model=TelegramWidgetConfigResponse)
|
||||
async def get_telegram_widget_config(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Get Telegram Login Widget configuration.
|
||||
This is a public endpoint - no authentication required.
|
||||
Returns widget display settings and bot username for the login page.
|
||||
"""
|
||||
bot_username = settings.BOT_USERNAME or ''
|
||||
|
||||
size_val = await get_setting_value(db, TELEGRAM_WIDGET_SIZE_KEY)
|
||||
radius_val = await get_setting_value(db, TELEGRAM_WIDGET_RADIUS_KEY)
|
||||
userpic_val = await get_setting_value(db, TELEGRAM_WIDGET_USERPIC_KEY)
|
||||
request_access_val = await get_setting_value(db, TELEGRAM_WIDGET_REQUEST_ACCESS_KEY)
|
||||
|
||||
oidc_enabled_val = await get_setting_value(db, TELEGRAM_OIDC_ENABLED_KEY)
|
||||
oidc_client_id_val = await get_setting_value(db, TELEGRAM_OIDC_CLIENT_ID_KEY)
|
||||
oidc_client_id = oidc_client_id_val or settings.TELEGRAM_OIDC_CLIENT_ID
|
||||
oidc_enabled = (
|
||||
oidc_enabled_val.lower() == 'true' if oidc_enabled_val is not None else settings.TELEGRAM_OIDC_ENABLED
|
||||
) and bool(oidc_client_id)
|
||||
|
||||
return TelegramWidgetConfigResponse(
|
||||
bot_username=bot_username,
|
||||
size=size_val if size_val in ('large', 'medium', 'small') else settings.TELEGRAM_WIDGET_SIZE,
|
||||
radius=max(0, min(int(radius_val), 20))
|
||||
if radius_val and radius_val.isdigit()
|
||||
else settings.TELEGRAM_WIDGET_RADIUS,
|
||||
userpic=userpic_val.lower() == 'true' if userpic_val is not None else settings.TELEGRAM_WIDGET_USERPIC,
|
||||
request_access=request_access_val.lower() == 'true'
|
||||
if request_access_val is not None
|
||||
else settings.TELEGRAM_WIDGET_REQUEST_ACCESS,
|
||||
oidc_enabled=oidc_enabled,
|
||||
oidc_client_id=oidc_client_id if oidc_enabled else '',
|
||||
)
|
||||
return EmailAuthEnabledResponse(enabled=payload.enabled)
|
||||
|
||||
|
||||
# ============ Analytics Counters Routes ============
|
||||
@@ -936,34 +684,17 @@ async def get_analytics_counters(
|
||||
google_id = await get_setting_value(db, GOOGLE_ADS_ID_KEY) or ''
|
||||
google_label = await get_setting_value(db, GOOGLE_ADS_LABEL_KEY) or ''
|
||||
|
||||
# Yandex Metrika offline conversions snapshot from Settings
|
||||
oc_enabled = bool(getattr(settings, 'YANDEX_OFFLINE_CONV_ENABLED', False))
|
||||
oc_counter = str(getattr(settings, 'YANDEX_OFFLINE_CONV_COUNTER_ID', '') or '')
|
||||
oc_secret = str(getattr(settings, 'YANDEX_OFFLINE_CONV_MEASUREMENT_SECRET', '') or '')
|
||||
oc_secret_masked = ('*' * 8 + oc_secret[-4:]) if len(oc_secret) > 4 else ('***' if oc_secret else '')
|
||||
oc_goals: list[OfflineConvGoal] = []
|
||||
if oc_enabled:
|
||||
oc_goals = [
|
||||
OfflineConvGoal(name='Registration', event_id='registration', dedup='user_id'),
|
||||
OfflineConvGoal(name='Trial', event_id='trial-add', dedup='user_id'),
|
||||
OfflineConvGoal(name='Purchase', event_id='purchase', dedup='order_id'),
|
||||
]
|
||||
|
||||
return AnalyticsCountersResponse(
|
||||
yandex_metrika_id=yandex_id,
|
||||
google_ads_id=google_id,
|
||||
google_ads_label=google_label,
|
||||
offline_conv_enabled=oc_enabled,
|
||||
offline_conv_counter_id=oc_counter,
|
||||
offline_conv_measurement_secret_masked=oc_secret_masked,
|
||||
offline_conv_goals=oc_goals,
|
||||
)
|
||||
|
||||
|
||||
@router.patch('/analytics', response_model=AnalyticsCountersResponse)
|
||||
async def update_analytics_counters(
|
||||
payload: AnalyticsCountersUpdate,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update analytics counter settings. Admin only. Partial update supported."""
|
||||
@@ -995,56 +726,13 @@ async def update_analytics_counters(
|
||||
google_id = await get_setting_value(db, GOOGLE_ADS_ID_KEY) or ''
|
||||
google_label = await get_setting_value(db, GOOGLE_ADS_LABEL_KEY) or ''
|
||||
|
||||
oc_enabled = bool(getattr(settings, 'YANDEX_OFFLINE_CONV_ENABLED', False))
|
||||
oc_counter = str(getattr(settings, 'YANDEX_OFFLINE_CONV_COUNTER_ID', '') or '')
|
||||
oc_secret = str(getattr(settings, 'YANDEX_OFFLINE_CONV_MEASUREMENT_SECRET', '') or '')
|
||||
oc_secret_masked = ('*' * 8 + oc_secret[-4:]) if len(oc_secret) > 4 else ('***' if oc_secret else '')
|
||||
oc_goals: list[OfflineConvGoal] = []
|
||||
if oc_enabled:
|
||||
oc_goals = [
|
||||
OfflineConvGoal(name='Registration', event_id='registration', dedup='user_id'),
|
||||
OfflineConvGoal(name='Trial', event_id='trial-add', dedup='user_id'),
|
||||
OfflineConvGoal(name='Purchase', event_id='purchase', dedup='order_id'),
|
||||
]
|
||||
|
||||
return AnalyticsCountersResponse(
|
||||
yandex_metrika_id=yandex_id,
|
||||
google_ads_id=google_id,
|
||||
google_ads_label=google_label,
|
||||
offline_conv_enabled=oc_enabled,
|
||||
offline_conv_counter_id=oc_counter,
|
||||
offline_conv_measurement_secret_masked=oc_secret_masked,
|
||||
offline_conv_goals=oc_goals,
|
||||
)
|
||||
|
||||
|
||||
# ============ Yandex CID Sync ============
|
||||
|
||||
|
||||
class YandexCidRequest(BaseModel):
|
||||
cid: str = Field(max_length=128, pattern=r'^[A-Za-z0-9._:-]{4,128}$')
|
||||
|
||||
|
||||
@router.post('/analytics/yandex-cid', status_code=204)
|
||||
async def store_yandex_cid(
|
||||
body: YandexCidRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Store Yandex Metrika ClientID for the authenticated cabinet user."""
|
||||
try:
|
||||
from app.services import yandex_offline_conv_service as yandex_conv
|
||||
|
||||
await yandex_conv.store_cid(db, user.id, body.cid, source='cabinet')
|
||||
await db.commit()
|
||||
except Exception as exc:
|
||||
logger.warning('Failed to store yandex_cid', user_id=user.id, exc=str(exc))
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ============ Lite Mode Routes ============
|
||||
|
||||
|
||||
@@ -1070,7 +758,7 @@ async def get_lite_mode_enabled(
|
||||
@router.patch('/lite-mode', response_model=LiteModeEnabledResponse)
|
||||
async def update_lite_mode_enabled(
|
||||
payload: LiteModeEnabledUpdate,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update lite mode enabled setting. Admin only."""
|
||||
@@ -1079,30 +767,3 @@ async def update_lite_mode_enabled(
|
||||
logger.info('Admin set lite mode enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
|
||||
|
||||
return LiteModeEnabledResponse(enabled=payload.enabled)
|
||||
|
||||
|
||||
# ============ Gift Feature Routes ============
|
||||
|
||||
|
||||
@router.get('/gift-enabled', response_model=GiftEnabledResponse)
|
||||
async def get_gift_enabled(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get gift feature enabled setting. Public endpoint."""
|
||||
value = await get_setting_value(db, GIFT_ENABLED_KEY)
|
||||
if value is not None:
|
||||
enabled = value.lower() == 'true'
|
||||
return GiftEnabledResponse(enabled=enabled)
|
||||
return GiftEnabledResponse(enabled=False)
|
||||
|
||||
|
||||
@router.patch('/gift-enabled', response_model=GiftEnabledResponse)
|
||||
async def update_gift_enabled(
|
||||
payload: GiftEnabledUpdate,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update gift feature enabled setting. Admin only."""
|
||||
await set_setting_value(db, GIFT_ENABLED_KEY, str(payload.enabled).lower())
|
||||
logger.info('Admin set gift enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
|
||||
return GiftEnabledResponse(enabled=payload.enabled)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Contests routes for cabinet - user participation in games/contests."""
|
||||
|
||||
import random
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
@@ -9,28 +9,14 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.contest import (
|
||||
create_attempt,
|
||||
get_active_rounds,
|
||||
get_attempt,
|
||||
increment_winner_count,
|
||||
)
|
||||
from app.database.crud.subscription import get_active_subscriptions_by_user_id, get_subscription_by_user_id
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.database.models import SubscriptionStatus, User
|
||||
|
||||
|
||||
async def _resolve_subscription_for_prize(db, user_id: int):
|
||||
"""Resolve best subscription for applying contest prize (days/traffic)."""
|
||||
if settings.is_multi_tariff_enabled():
|
||||
active_subs = await get_active_subscriptions_by_user_id(db, user_id)
|
||||
# Prefer non-daily with most days left
|
||||
non_daily = [s for s in active_subs if not (s.tariff and getattr(s.tariff, 'is_daily', False))]
|
||||
eligible = non_daily or active_subs
|
||||
return max(eligible, key=lambda s: s.days_left) if eligible else None
|
||||
return await get_subscription_by_user_id(db, user_id)
|
||||
|
||||
|
||||
from app.services.contest_rotation_service import (
|
||||
GAME_ANAGRAM,
|
||||
GAME_BLITZ,
|
||||
@@ -100,7 +86,6 @@ def _user_allowed(subscription) -> bool:
|
||||
return subscription.status in {
|
||||
SubscriptionStatus.ACTIVE.value,
|
||||
SubscriptionStatus.TRIAL.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
}
|
||||
|
||||
|
||||
@@ -112,12 +97,12 @@ async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_va
|
||||
except ValueError:
|
||||
return 'Error: invalid prize value'
|
||||
|
||||
subscription = await _resolve_subscription_for_prize(db, user_id)
|
||||
subscription = await get_subscription_by_user_id(db, user_id)
|
||||
if not subscription:
|
||||
return 'Error: subscription not found'
|
||||
|
||||
subscription.end_date = subscription.end_date + timedelta(days=days)
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
@@ -136,10 +121,7 @@ async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_va
|
||||
if not user:
|
||||
return 'Error: user not found'
|
||||
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
user.balance_kopeks += int(round(amount * 100))
|
||||
user.balance += amount
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
@@ -165,7 +147,7 @@ async def get_contests_count(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get count of contests available for the user."""
|
||||
subscription = await _resolve_subscription_for_prize(db, user.id)
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
if not _user_allowed(subscription):
|
||||
return ContestsCountResponse(count=0)
|
||||
@@ -197,7 +179,7 @@ async def get_contests(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of available contests/games."""
|
||||
subscription = await _resolve_subscription_for_prize(db, user.id)
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
if not _user_allowed(subscription):
|
||||
raise HTTPException(
|
||||
@@ -244,7 +226,7 @@ async def get_contest_game(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get game data for a specific contest round."""
|
||||
subscription = await _resolve_subscription_for_prize(db, user.id)
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
if not _user_allowed(subscription):
|
||||
raise HTTPException(
|
||||
@@ -364,7 +346,7 @@ async def submit_contest_answer(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Submit answer for a contest round."""
|
||||
subscription = await _resolve_subscription_for_prize(db, user.id)
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
if not _user_allowed(subscription):
|
||||
raise HTTPException(
|
||||
|
||||
@@ -1,806 +0,0 @@
|
||||
"""Gift subscription routes for cabinet."""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.system_setting import get_setting_value
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.database.crud.transaction import create_transaction, emit_transaction_side_effects
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import (
|
||||
GuestPurchase,
|
||||
GuestPurchaseStatus,
|
||||
PaymentMethod,
|
||||
Tariff,
|
||||
TransactionType,
|
||||
User,
|
||||
)
|
||||
from app.services.guest_purchase_service import (
|
||||
GuestPurchaseError,
|
||||
create_purchase,
|
||||
fulfill_purchase,
|
||||
)
|
||||
from app.services.payment_method_config_service import get_enabled_methods_for_user
|
||||
from app.utils.cache import RateLimitCache
|
||||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.gift import (
|
||||
ActivateGiftRequest,
|
||||
ActivateGiftResponse,
|
||||
GiftConfigPaymentMethod,
|
||||
GiftConfigResponse,
|
||||
GiftConfigSubOption,
|
||||
GiftConfigTariff,
|
||||
GiftConfigTariffPeriod,
|
||||
GiftPurchaseRequest,
|
||||
GiftPurchaseResponse,
|
||||
GiftPurchaseStatusResponse,
|
||||
PendingGiftResponse,
|
||||
ReceivedGiftResponse,
|
||||
SentGiftResponse,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/gift', tags=['Cabinet Gift'])
|
||||
|
||||
GIFT_ENABLED_KEY = 'CABINET_GIFT_ENABLED'
|
||||
|
||||
_EMAIL_RE = re.compile(r'^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$')
|
||||
_TELEGRAM_RE = re.compile(r'^@?[a-zA-Z][a-zA-Z0-9_]{4,31}$')
|
||||
|
||||
|
||||
async def _is_gift_enabled(db: AsyncSession) -> bool:
|
||||
"""Check if the gift feature is enabled via system settings."""
|
||||
value = await get_setting_value(db, GIFT_ENABLED_KEY)
|
||||
if value is not None:
|
||||
return value.lower() == 'true'
|
||||
return False
|
||||
|
||||
|
||||
@router.get('/config', response_model=GiftConfigResponse)
|
||||
async def get_gift_config(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get gift subscription configuration: tariffs, payment methods, balance."""
|
||||
enabled = await _is_gift_enabled(db)
|
||||
if not enabled:
|
||||
return GiftConfigResponse(
|
||||
is_enabled=False,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
)
|
||||
|
||||
# Load active tariffs visible in gift section
|
||||
result = await db.execute(
|
||||
select(Tariff)
|
||||
.where(Tariff.is_active.is_(True), Tariff.show_in_gift.is_(True))
|
||||
.order_by(Tariff.display_order, Tariff.id)
|
||||
)
|
||||
tariffs_db = result.scalars().all()
|
||||
|
||||
# Get user's promo group for discount calculation
|
||||
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
|
||||
if promo_group is None:
|
||||
promo_group = getattr(user, 'promo_group', None)
|
||||
promo_group_name = promo_group.name if promo_group else None
|
||||
|
||||
# Get active promo offer discount
|
||||
promo_offer_discount_percent = get_user_active_promo_discount_percent(user)
|
||||
|
||||
tariffs: list[GiftConfigTariff] = []
|
||||
for tariff in tariffs_db:
|
||||
period_days_list = tariff.get_available_periods()
|
||||
periods: list[GiftConfigTariffPeriod] = []
|
||||
for days in period_days_list:
|
||||
base_price = tariff.get_price_for_period(days)
|
||||
if base_price is None:
|
||||
continue
|
||||
|
||||
original_price = base_price
|
||||
price = base_price
|
||||
|
||||
# Apply promo group discount
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
promo_group_discount = 0
|
||||
if promo_group:
|
||||
promo_group_discount = promo_group.get_discount_percent('period', days)
|
||||
if promo_group_discount > 0:
|
||||
price = PricingEngine.apply_discount(price, promo_group_discount)
|
||||
|
||||
# Apply active promo offer discount (stacks on top)
|
||||
if promo_offer_discount_percent > 0:
|
||||
price = PricingEngine.apply_discount(price, promo_offer_discount_percent)
|
||||
|
||||
# Ensure minimum price of 1 kopek after all discounts
|
||||
price = max(1, price)
|
||||
|
||||
# Calculate combined discount percent
|
||||
combined_discount = 0
|
||||
if original_price > 0 and original_price != price:
|
||||
combined_discount = int((original_price - price) * 100 / original_price)
|
||||
|
||||
periods.append(
|
||||
GiftConfigTariffPeriod(
|
||||
days=days,
|
||||
price_kopeks=price,
|
||||
price_label=settings.format_price(price),
|
||||
original_price_kopeks=original_price if combined_discount > 0 else None,
|
||||
discount_percent=combined_discount if combined_discount > 0 else None,
|
||||
)
|
||||
)
|
||||
if not periods:
|
||||
continue
|
||||
tariffs.append(
|
||||
GiftConfigTariff(
|
||||
id=tariff.id,
|
||||
name=tariff.name,
|
||||
description=tariff.description,
|
||||
traffic_limit_gb=tariff.traffic_limit_gb,
|
||||
device_limit=tariff.device_limit,
|
||||
periods=periods,
|
||||
)
|
||||
)
|
||||
|
||||
# Load payment methods available for this user
|
||||
enabled_methods = await get_enabled_methods_for_user(db, user=user)
|
||||
payment_methods: list[GiftConfigPaymentMethod] = []
|
||||
for method_data in enabled_methods:
|
||||
sub_options = None
|
||||
raw_options = method_data.get('options')
|
||||
if raw_options:
|
||||
sub_options = [GiftConfigSubOption(id=opt['id'], name=opt.get('name', opt['id'])) for opt in raw_options]
|
||||
payment_methods.append(
|
||||
GiftConfigPaymentMethod(
|
||||
method_id=method_data['id'],
|
||||
display_name=method_data['name'],
|
||||
min_amount_kopeks=method_data.get('min_amount_kopeks'),
|
||||
max_amount_kopeks=method_data.get('max_amount_kopeks'),
|
||||
sub_options=sub_options,
|
||||
)
|
||||
)
|
||||
|
||||
return GiftConfigResponse(
|
||||
is_enabled=True,
|
||||
tariffs=tariffs,
|
||||
payment_methods=payment_methods,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
currency_symbol=getattr(settings, 'CURRENCY_SYMBOL', '\u20bd'),
|
||||
promo_group_name=promo_group_name,
|
||||
active_discount_percent=promo_offer_discount_percent if promo_offer_discount_percent > 0 else None,
|
||||
active_discount_expires_at=(
|
||||
getattr(user, 'promo_offer_discount_expires_at', None) if promo_offer_discount_percent > 0 else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post('/purchase', response_model=GiftPurchaseResponse)
|
||||
async def create_gift_purchase(
|
||||
body: GiftPurchaseRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Create a gift subscription purchase from the cabinet."""
|
||||
enabled = await _is_gift_enabled(db)
|
||||
if not enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Gift feature is not enabled',
|
||||
)
|
||||
|
||||
# Rate limit: 5 gift purchases per 60 seconds per user
|
||||
is_limited = await RateLimitCache.is_rate_limited(user.id, 'gift_purchase', limit=5, window=60)
|
||||
if is_limited:
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
|
||||
|
||||
# Check if user has purchase restrictions
|
||||
if getattr(user, 'restriction_subscription', False):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Purchases are restricted for this account',
|
||||
)
|
||||
|
||||
# Recipient is optional — when omitted, buyer gets a code to share manually
|
||||
has_recipient = bool(body.recipient_type and body.recipient_value)
|
||||
|
||||
if has_recipient:
|
||||
# Validate recipient format
|
||||
if body.recipient_type == 'email' and not _EMAIL_RE.match(body.recipient_value):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid email format',
|
||||
)
|
||||
if body.recipient_type == 'telegram' and not _TELEGRAM_RE.match(body.recipient_value):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid Telegram username format',
|
||||
)
|
||||
|
||||
# Prevent self-gift
|
||||
if body.recipient_type == 'telegram':
|
||||
normalized_recipient = body.recipient_value.lstrip('@').lower()
|
||||
if user.username and user.username.lower() == normalized_recipient:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Cannot gift to yourself',
|
||||
)
|
||||
elif body.recipient_type == 'email':
|
||||
if user.email and user.email.lower() == body.recipient_value.lower():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Cannot gift to yourself',
|
||||
)
|
||||
|
||||
# Find tariff and validate period
|
||||
tariff = await get_tariff_by_id(db, body.tariff_id)
|
||||
if tariff is None or not tariff.is_active or not tariff.show_in_gift:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Tariff not found or inactive',
|
||||
)
|
||||
|
||||
# Validate that period has a configured price before locking
|
||||
if tariff.get_price_for_period(body.period_days) is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Price is not configured for this period',
|
||||
)
|
||||
|
||||
# Lock user BEFORE price computation to prevent TOCTOU on promo offer
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
pricing_result = await pricing_engine.calculate_tariff_purchase_price(
|
||||
tariff,
|
||||
body.period_days,
|
||||
device_limit=tariff.device_limit,
|
||||
user=user,
|
||||
)
|
||||
price_kopeks = max(1, pricing_result.final_total)
|
||||
consume_promo = pricing_result.promo_offer_discount > 0
|
||||
|
||||
# Determine buyer contact info
|
||||
if user.email:
|
||||
buyer_contact_type = 'email'
|
||||
buyer_contact_value = user.email
|
||||
elif user.username:
|
||||
buyer_contact_type = 'telegram'
|
||||
buyer_contact_value = f'@{user.username}'
|
||||
else:
|
||||
buyer_contact_type = 'telegram'
|
||||
buyer_contact_value = f'id:{user.telegram_id or user.id}'
|
||||
|
||||
# Pre-check: try to resolve Telegram username — DB first, then Bot API.
|
||||
# Only relevant when a recipient is explicitly specified.
|
||||
recipient_warning: str | None = None
|
||||
pre_resolved_telegram_id: int | None = None
|
||||
if has_recipient and body.recipient_type == 'telegram':
|
||||
tg_username = body.recipient_value.lstrip('@')
|
||||
normalized_username = tg_username.lower()
|
||||
|
||||
# 1) Check local DB — user may already be registered in the bot
|
||||
db_result = await db.execute(
|
||||
select(User.telegram_id).where(
|
||||
func.lower(User.username) == normalized_username,
|
||||
User.telegram_id.isnot(None),
|
||||
)
|
||||
)
|
||||
db_telegram_id = db_result.scalar_one_or_none()
|
||||
|
||||
if db_telegram_id is not None:
|
||||
pre_resolved_telegram_id = db_telegram_id
|
||||
else:
|
||||
# 2) Fall back to Bot API (works for public usernames the bot has seen)
|
||||
try:
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
async with create_bot() as bot:
|
||||
chat = await asyncio.wait_for(bot.get_chat(chat_id=f'@{tg_username}'), timeout=5.0)
|
||||
pre_resolved_telegram_id = chat.id
|
||||
except Exception:
|
||||
recipient_warning = 'telegram_unresolvable'
|
||||
logger.warning(
|
||||
'Telegram username not resolvable for gift',
|
||||
username=tg_username,
|
||||
buyer_id=user.id,
|
||||
)
|
||||
|
||||
# Gateway mode: create payment via external provider
|
||||
if body.payment_mode == 'gateway':
|
||||
if not body.payment_method:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='payment_method is required for gateway mode',
|
||||
)
|
||||
|
||||
purchase_kwargs: dict = (
|
||||
{
|
||||
'gift_recipient_type': body.recipient_type,
|
||||
'gift_recipient_value': body.recipient_value,
|
||||
'gift_message': body.gift_message,
|
||||
}
|
||||
if has_recipient
|
||||
else {
|
||||
'gift_message': body.gift_message,
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
purchase = await create_purchase(
|
||||
db,
|
||||
landing=None,
|
||||
tariff=tariff,
|
||||
period_days=body.period_days,
|
||||
amount_kopeks=price_kopeks,
|
||||
contact_type=buyer_contact_type,
|
||||
contact_value=buyer_contact_value,
|
||||
payment_method=body.payment_method,
|
||||
is_gift=True,
|
||||
source='cabinet',
|
||||
buyer_user_id=user.id,
|
||||
commit=False,
|
||||
**purchase_kwargs,
|
||||
)
|
||||
except GuestPurchaseError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
|
||||
|
||||
# Persist warning so it survives the gateway redirect
|
||||
if recipient_warning:
|
||||
purchase.recipient_warning = recipient_warning
|
||||
|
||||
# Build return URL for after payment
|
||||
cabinet_base = (settings.CABINET_URL or '').rstrip('/')
|
||||
return_url = f'{cabinet_base}/gift/result?token={purchase.token[:12]}'
|
||||
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
# Stars payments need a Bot instance to create invoice links
|
||||
bot = None
|
||||
if body.payment_method == 'telegram_stars':
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
bot = create_bot()
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(bot=bot)
|
||||
payment_result = await payment_service.create_guest_payment(
|
||||
db=db,
|
||||
amount_kopeks=price_kopeks,
|
||||
payment_method=body.payment_method,
|
||||
description=f'Gift: {tariff.name} ({body.period_days}d)',
|
||||
purchase_token=purchase.token,
|
||||
return_url=return_url,
|
||||
)
|
||||
finally:
|
||||
if bot:
|
||||
await bot.session.close()
|
||||
|
||||
if payment_result is None:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail='Payment provider is unavailable, please try again later',
|
||||
)
|
||||
|
||||
payment_url = payment_result.get('payment_url')
|
||||
if not payment_url:
|
||||
await db.rollback()
|
||||
logger.error(
|
||||
'Gift payment created but no payment_url returned',
|
||||
purchase_token=purchase.token[:5],
|
||||
provider=payment_result.get('provider'),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail='Payment provider returned an invalid response',
|
||||
)
|
||||
|
||||
# Consume promo offer discount before committing gateway purchase
|
||||
if consume_promo and getattr(user, 'promo_offer_discount_percent', 0):
|
||||
user.promo_offer_discount_percent = 0
|
||||
user.promo_offer_discount_source = None
|
||||
user.promo_offer_discount_expires_at = None
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(purchase)
|
||||
|
||||
return GiftPurchaseResponse(
|
||||
status='created',
|
||||
purchase_token=purchase.token[:12],
|
||||
payment_url=payment_url,
|
||||
warning=recipient_warning,
|
||||
)
|
||||
|
||||
# Balance mode (skip for 100% discount)
|
||||
if price_kopeks > 0 and user.balance_kopeks < price_kopeks:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Insufficient balance',
|
||||
)
|
||||
|
||||
# Create purchase record
|
||||
balance_purchase_kwargs: dict = (
|
||||
{
|
||||
'gift_recipient_type': body.recipient_type,
|
||||
'gift_recipient_value': body.recipient_value,
|
||||
'gift_message': body.gift_message,
|
||||
}
|
||||
if has_recipient
|
||||
else {
|
||||
'gift_message': body.gift_message,
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
purchase = await create_purchase(
|
||||
db,
|
||||
landing=None,
|
||||
tariff=tariff,
|
||||
period_days=body.period_days,
|
||||
amount_kopeks=price_kopeks,
|
||||
contact_type=buyer_contact_type,
|
||||
contact_value=buyer_contact_value,
|
||||
payment_method='balance',
|
||||
is_gift=True,
|
||||
source='cabinet',
|
||||
buyer_user_id=user.id,
|
||||
commit=False,
|
||||
**balance_purchase_kwargs,
|
||||
)
|
||||
except GuestPurchaseError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
|
||||
|
||||
# Persist warning on purchase record
|
||||
if recipient_warning:
|
||||
purchase.recipient_warning = recipient_warning
|
||||
|
||||
# Subtract balance (consume promo offer if one was applied)
|
||||
balance_ok = await subtract_user_balance(
|
||||
db,
|
||||
user,
|
||||
price_kopeks,
|
||||
description=f'Gift: {tariff.name} ({body.period_days}d)',
|
||||
create_transaction=False,
|
||||
consume_promo_offer=consume_promo,
|
||||
)
|
||||
if not balance_ok:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Insufficient balance',
|
||||
)
|
||||
|
||||
# Transaction description: include recipient when specified
|
||||
tx_description = f'Gift: {tariff.name} ({body.period_days}d)'
|
||||
if has_recipient:
|
||||
tx_description += f' -> {body.recipient_value}'
|
||||
|
||||
# Create transaction record
|
||||
transaction = await create_transaction(
|
||||
db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.GIFT_PAYMENT,
|
||||
amount_kopeks=price_kopeks,
|
||||
description=tx_description,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
# Mark purchase as paid
|
||||
purchase.status = GuestPurchaseStatus.PAID.value
|
||||
purchase.paid_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Emit deferred side-effects after atomic commit
|
||||
await emit_transaction_side_effects(
|
||||
db,
|
||||
transaction,
|
||||
amount_kopeks=price_kopeks,
|
||||
user_id=user.id,
|
||||
type=TransactionType.GIFT_PAYMENT,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
description=tx_description,
|
||||
)
|
||||
|
||||
# Capture token before fulfill_purchase — session state may change after rollback inside fulfill
|
||||
purchase_token = purchase.token
|
||||
|
||||
# Only fulfill immediately when a specific recipient was provided.
|
||||
# Code-only gifts (no recipient) stay in PAID status until someone activates via code.
|
||||
if has_recipient:
|
||||
try:
|
||||
await fulfill_purchase(db, purchase_token, pre_resolved_telegram_id=pre_resolved_telegram_id)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
'Gift purchase fulfillment failed (purchase is paid, will retry)',
|
||||
purchase_id=purchase.id,
|
||||
)
|
||||
|
||||
return GiftPurchaseResponse(
|
||||
status='ok',
|
||||
purchase_token=purchase_token[:12],
|
||||
warning=recipient_warning,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/pending', response_model=list[PendingGiftResponse])
|
||||
async def get_pending_gifts(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get pending gift purchases that the current user can activate."""
|
||||
result = await db.execute(
|
||||
select(GuestPurchase)
|
||||
.options(selectinload(GuestPurchase.tariff))
|
||||
.where(
|
||||
GuestPurchase.user_id == user.id,
|
||||
GuestPurchase.is_gift.is_(True),
|
||||
GuestPurchase.status == GuestPurchaseStatus.PENDING_ACTIVATION.value,
|
||||
)
|
||||
.order_by(GuestPurchase.created_at.desc())
|
||||
.limit(100)
|
||||
)
|
||||
purchases = result.scalars().all()
|
||||
|
||||
pending: list[PendingGiftResponse] = []
|
||||
for p in purchases:
|
||||
# Determine sender display name
|
||||
sender_display = None
|
||||
if p.contact_value:
|
||||
sender_display = p.contact_value
|
||||
|
||||
pending.append(
|
||||
PendingGiftResponse(
|
||||
token=p.token[:12],
|
||||
tariff_name=p.tariff.name if p.tariff else None,
|
||||
period_days=p.period_days,
|
||||
gift_message=p.gift_message,
|
||||
sender_display=sender_display,
|
||||
created_at=p.created_at,
|
||||
)
|
||||
)
|
||||
|
||||
return pending
|
||||
|
||||
|
||||
@router.get('/purchase/{token}', response_model=GiftPurchaseStatusResponse)
|
||||
async def get_gift_purchase_status(
|
||||
token: str,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get the status of a cabinet gift purchase."""
|
||||
if len(token) >= 64:
|
||||
token_filter = GuestPurchase.token == token
|
||||
else:
|
||||
token_filter = GuestPurchase.token.startswith(token)
|
||||
|
||||
result = await db.execute(select(GuestPurchase).options(selectinload(GuestPurchase.tariff)).where(token_filter))
|
||||
purchase = result.scalars().first()
|
||||
if purchase is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Purchase not found',
|
||||
)
|
||||
|
||||
# Uniform 404 prevents token existence oracle
|
||||
if purchase.buyer_user_id != user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Purchase not found',
|
||||
)
|
||||
|
||||
tariff_name = purchase.tariff.name if purchase.tariff else None
|
||||
|
||||
recipient_contact_value = None
|
||||
if purchase.gift_recipient_value:
|
||||
recipient_contact_value = purchase.gift_recipient_value
|
||||
|
||||
is_code_only = purchase.is_gift and not purchase.gift_recipient_type
|
||||
|
||||
return GiftPurchaseStatusResponse(
|
||||
status=purchase.status,
|
||||
is_gift=True,
|
||||
is_code_only=is_code_only,
|
||||
purchase_token=purchase.token[:12] if is_code_only else None,
|
||||
recipient_contact_value=recipient_contact_value,
|
||||
gift_message=purchase.gift_message,
|
||||
tariff_name=tariff_name,
|
||||
period_days=purchase.period_days,
|
||||
warning=purchase.recipient_warning,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/sent', response_model=list[SentGiftResponse])
|
||||
async def get_sent_gifts(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get all gifts the current user has sent."""
|
||||
result = await db.execute(
|
||||
select(GuestPurchase)
|
||||
.options(selectinload(GuestPurchase.tariff), selectinload(GuestPurchase.user))
|
||||
.where(
|
||||
GuestPurchase.buyer_user_id == user.id,
|
||||
GuestPurchase.is_gift.is_(True),
|
||||
)
|
||||
.order_by(GuestPurchase.created_at.desc())
|
||||
.limit(100)
|
||||
)
|
||||
purchases = result.scalars().all()
|
||||
|
||||
sent: list[SentGiftResponse] = []
|
||||
for p in purchases:
|
||||
activated_by_username = None
|
||||
if p.status == GuestPurchaseStatus.DELIVERED.value and p.user and p.user.username:
|
||||
activated_by_username = f'@{p.user.username}'
|
||||
|
||||
sent.append(
|
||||
SentGiftResponse(
|
||||
token=p.token[:12],
|
||||
tariff_name=p.tariff.name if p.tariff else None,
|
||||
period_days=p.period_days,
|
||||
device_limit=p.tariff.device_limit if p.tariff else 1,
|
||||
status=p.status,
|
||||
gift_recipient_value=p.gift_recipient_value,
|
||||
gift_message=p.gift_message,
|
||||
activated_by_username=activated_by_username,
|
||||
created_at=p.created_at,
|
||||
)
|
||||
)
|
||||
|
||||
return sent
|
||||
|
||||
|
||||
@router.get('/received', response_model=list[ReceivedGiftResponse])
|
||||
async def get_received_gifts(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get all gifts the current user has received."""
|
||||
result = await db.execute(
|
||||
select(GuestPurchase)
|
||||
.options(selectinload(GuestPurchase.tariff), selectinload(GuestPurchase.buyer))
|
||||
.where(
|
||||
GuestPurchase.user_id == user.id,
|
||||
GuestPurchase.is_gift.is_(True),
|
||||
)
|
||||
.order_by(GuestPurchase.created_at.desc())
|
||||
.limit(100)
|
||||
)
|
||||
purchases = result.scalars().all()
|
||||
|
||||
received: list[ReceivedGiftResponse] = []
|
||||
for p in purchases:
|
||||
sender_display = None
|
||||
if p.buyer and p.buyer.username:
|
||||
sender_display = f'@{p.buyer.username}'
|
||||
elif p.contact_value:
|
||||
sender_display = p.contact_value
|
||||
|
||||
received.append(
|
||||
ReceivedGiftResponse(
|
||||
token=p.token[:12],
|
||||
tariff_name=p.tariff.name if p.tariff else None,
|
||||
period_days=p.period_days,
|
||||
device_limit=p.tariff.device_limit if p.tariff else 1,
|
||||
status=p.status,
|
||||
sender_display=sender_display,
|
||||
gift_message=p.gift_message,
|
||||
created_at=p.created_at,
|
||||
)
|
||||
)
|
||||
|
||||
return received
|
||||
|
||||
|
||||
@router.post('/activate', response_model=ActivateGiftResponse)
|
||||
async def activate_gift_by_code(
|
||||
body: ActivateGiftRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Activate a gift subscription by its code (token)."""
|
||||
from app.services.guest_purchase_service import activate_purchase as svc_activate
|
||||
|
||||
# Bug 2 fix: rate limit activation attempts to prevent brute-force token enumeration
|
||||
is_limited = await RateLimitCache.is_rate_limited(user.id, 'gift_activate', limit=10, window=60)
|
||||
if is_limited:
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
|
||||
|
||||
code = body.code.strip()
|
||||
if code.upper().startswith('GIFT-') or code.upper().startswith('GIFT_'):
|
||||
code = code[5:]
|
||||
|
||||
if len(code) < 8:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Code too short')
|
||||
|
||||
# Support both full token and prefix-based lookup (displayed codes are truncated)
|
||||
if len(code) >= 64:
|
||||
# Full token — exact match
|
||||
token_filter = GuestPurchase.token == code
|
||||
else:
|
||||
# Prefix match — for short display codes like GIFT-XXXXXXXXXXXX
|
||||
token_filter = GuestPurchase.token.startswith(code)
|
||||
|
||||
result = await db.execute(
|
||||
select(GuestPurchase)
|
||||
.options(selectinload(GuestPurchase.tariff))
|
||||
.where(token_filter, GuestPurchase.is_gift.is_(True))
|
||||
.with_for_update()
|
||||
)
|
||||
purchase = result.scalars().first()
|
||||
|
||||
if purchase is None or not purchase.is_gift:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Gift not found',
|
||||
)
|
||||
|
||||
# Bug 1 fix: check ownership BEFORE leaking any status/tariff info
|
||||
if purchase.user_id is not None and purchase.user_id != user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Gift not found',
|
||||
)
|
||||
|
||||
# Prevent self-activation: buyer cannot activate their own gift
|
||||
if purchase.buyer_user_id is not None and purchase.buyer_user_id == user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Cannot activate your own gift',
|
||||
)
|
||||
|
||||
if purchase.status == GuestPurchaseStatus.DELIVERED.value:
|
||||
return ActivateGiftResponse(
|
||||
status='activated',
|
||||
tariff_name=purchase.tariff.name if purchase.tariff else None,
|
||||
period_days=purchase.period_days,
|
||||
)
|
||||
|
||||
# Code-only gifts are in PAID status; directed gifts are in PENDING_ACTIVATION
|
||||
activatable_statuses = {
|
||||
GuestPurchaseStatus.PENDING_ACTIVATION.value,
|
||||
GuestPurchaseStatus.PAID.value,
|
||||
}
|
||||
if purchase.status not in activatable_statuses:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='This gift cannot be activated',
|
||||
)
|
||||
|
||||
# For code-only gifts (user_id is None), link the purchase to the activating user
|
||||
if purchase.user_id is None:
|
||||
purchase.user_id = user.id
|
||||
|
||||
# Transition PAID → PENDING_ACTIVATION so activate_purchase() accepts it
|
||||
if purchase.status == GuestPurchaseStatus.PAID.value:
|
||||
purchase.status = GuestPurchaseStatus.PENDING_ACTIVATION.value
|
||||
|
||||
await db.flush()
|
||||
|
||||
try:
|
||||
await svc_activate(db, purchase.token, skip_notification=True)
|
||||
except GuestPurchaseError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
|
||||
|
||||
return ActivateGiftResponse(
|
||||
status='activated',
|
||||
tariff_name=purchase.tariff.name if purchase.tariff else None,
|
||||
period_days=purchase.period_days,
|
||||
)
|
||||
@@ -91,7 +91,7 @@ class SupportConfigResponse(BaseModel):
|
||||
"""Support/tickets configuration for miniapp."""
|
||||
|
||||
tickets_enabled: bool
|
||||
support_type: str # "tickets", "profile", "url", "both"
|
||||
support_type: str # "tickets", "profile", "url"
|
||||
support_url: str | None = None
|
||||
support_username: str | None = None
|
||||
|
||||
@@ -160,7 +160,7 @@ async def get_rules(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get service rules - uses same function as bot."""
|
||||
requested_lang = language.split('-', maxsplit=1)[0].lower()
|
||||
requested_lang = language.split('-')[0].lower()
|
||||
|
||||
# Use the same function as bot to ensure consistent content
|
||||
content = await get_current_rules_content(db, requested_lang)
|
||||
@@ -299,7 +299,7 @@ async def get_support_config():
|
||||
support_type = 'profile'
|
||||
else: # both
|
||||
tickets_enabled = True
|
||||
support_type = 'both'
|
||||
support_type = 'tickets'
|
||||
|
||||
return SupportConfigResponse(
|
||||
tickets_enabled=tickets_enabled,
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
"""Public info page routes for cabinet."""
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.info_pages import get_all_info_pages, get_info_page_by_slug, get_tab_replacements
|
||||
|
||||
from ..dependencies import get_cabinet_db
|
||||
from ..schemas.info_pages import InfoPageListItem, InfoPageResponse
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/info-pages', tags=['Cabinet Info Pages'])
|
||||
|
||||
|
||||
@router.get('', response_model=list[InfoPageListItem])
|
||||
async def list_active_info_pages(
|
||||
page_type: str | None = Query(None, pattern=r'^(page|faq)$'),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> list[InfoPageListItem]:
|
||||
"""Get all active info pages (public, no auth required)."""
|
||||
try:
|
||||
pages = await get_all_info_pages(db, include_inactive=False, page_type=page_type)
|
||||
return [InfoPageListItem.model_validate(p) for p in pages]
|
||||
except Exception:
|
||||
logger.exception('Failed to list active info pages')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load info pages',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/tab-replacements')
|
||||
async def get_info_page_tab_replacements(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict[str, str | None]:
|
||||
"""Get tab replacement mapping (public, no auth required).
|
||||
|
||||
Returns a dict mapping each replaceable tab to the info page slug that replaces it,
|
||||
or null if no replacement is set: ``{faq: slug_or_null, ...}``.
|
||||
"""
|
||||
try:
|
||||
return await get_tab_replacements(db)
|
||||
except Exception:
|
||||
logger.exception('Failed to get tab replacements')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load tab replacements',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{slug}', response_model=InfoPageResponse)
|
||||
async def get_info_page_by_slug_public(
|
||||
slug: str = Path(..., max_length=200, pattern=r'^[a-z0-9\-]+$'),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> InfoPageResponse:
|
||||
"""Get a single info page by slug (public, no auth required)."""
|
||||
page = await get_info_page_by_slug(db, slug)
|
||||
|
||||
if not page or not page.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Info page not found',
|
||||
)
|
||||
|
||||
return InfoPageResponse.model_validate(page)
|
||||
@@ -1,731 +0,0 @@
|
||||
"""Public landing page routes for guest quick-purchase flow."""
|
||||
|
||||
import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request, status
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.cabinet.dependencies import get_cabinet_db
|
||||
from app.cabinet.ip_utils import get_client_ip
|
||||
from app.cabinet.utils.locale import DEFAULT_LOCALE, resolve_locale_text
|
||||
from app.config import settings
|
||||
from app.database.crud.landing import get_active_landing_by_slug, get_purchase_by_token
|
||||
from app.database.models import GuestPurchase, GuestPurchaseStatus, LandingPage, Tariff
|
||||
from app.services.guest_purchase_service import (
|
||||
GuestPurchaseError,
|
||||
activate_purchase as activate_guest_purchase,
|
||||
create_purchase,
|
||||
validate_and_calculate,
|
||||
)
|
||||
from app.services.payment_method_config_service import _get_method_defaults
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.utils.cache import RateLimitCache, cache
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/landing', tags=['Landing Pages'])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
|
||||
class LandingFeature(BaseModel):
|
||||
icon: str = ''
|
||||
title: str = ''
|
||||
description: str = ''
|
||||
|
||||
|
||||
class LandingTariffPeriod(BaseModel):
|
||||
days: int
|
||||
label: str
|
||||
price_kopeks: int
|
||||
price_label: str
|
||||
original_price_kopeks: int | None = None # set if discount active
|
||||
original_price_label: str | None = None
|
||||
discount_percent: int | None = None # effective discount for this tariff
|
||||
|
||||
|
||||
class LandingTariff(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: str | None = None
|
||||
traffic_limit_gb: int
|
||||
device_limit: int
|
||||
tier_level: int
|
||||
periods: list[LandingTariffPeriod]
|
||||
|
||||
|
||||
class LandingPaymentMethodSubOption(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
class LandingPaymentMethod(BaseModel):
|
||||
method_id: str
|
||||
display_name: str
|
||||
description: str | None = None
|
||||
icon_url: str | None = None
|
||||
sort_order: int = 0
|
||||
min_amount_kopeks: int | None = None
|
||||
max_amount_kopeks: int | None = None
|
||||
currency: str | None = None
|
||||
# Enabled sub-options with display labels (e.g. СБП, Карта).
|
||||
# None or empty means no sub-option selection needed.
|
||||
sub_options: list[LandingPaymentMethodSubOption] | None = None
|
||||
|
||||
|
||||
class LandingDiscountInfo(BaseModel):
|
||||
percent: int # default discount
|
||||
ends_at: str # ISO datetime
|
||||
badge_text: str | None = None # resolved locale text
|
||||
|
||||
|
||||
class LandingConfigResponse(BaseModel):
|
||||
slug: str
|
||||
title: str
|
||||
subtitle: str | None = None
|
||||
features: list[LandingFeature]
|
||||
footer_text: str | None = None
|
||||
tariffs: list[LandingTariff]
|
||||
payment_methods: list[LandingPaymentMethod]
|
||||
gift_enabled: bool
|
||||
custom_css: str | None = None
|
||||
meta_title: str | None = None
|
||||
meta_description: str | None = None
|
||||
discount: LandingDiscountInfo | None = None # null if no active discount
|
||||
background_config: dict | None = None
|
||||
sticky_pay_button: bool = False
|
||||
analytics_view_enabled: bool = False
|
||||
analytics_view_goal: str | None = None
|
||||
analytics_click_enabled: bool = False
|
||||
analytics_click_goal: str | None = None
|
||||
|
||||
|
||||
_EMAIL_RE = re.compile(r'^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$')
|
||||
_TELEGRAM_RE = re.compile(r'^@?[a-zA-Z][a-zA-Z0-9_]{4,31}$')
|
||||
|
||||
|
||||
def _validate_contact(contact_type: str, contact_value: str) -> None:
|
||||
"""Validate contact value matches the declared type format."""
|
||||
if contact_type == 'email' and not _EMAIL_RE.match(contact_value):
|
||||
raise ValueError('Invalid email format')
|
||||
if contact_type == 'telegram' and not _TELEGRAM_RE.match(contact_value):
|
||||
raise ValueError('Invalid Telegram username format')
|
||||
|
||||
|
||||
class PurchaseRequest(BaseModel):
|
||||
tariff_id: int
|
||||
period_days: int
|
||||
contact_type: str = Field(pattern=r'^(email|telegram)$')
|
||||
contact_value: str = Field(min_length=1, max_length=255)
|
||||
payment_method: str = Field(min_length=1, max_length=50, pattern=r'^[a-z0-9_]+$')
|
||||
is_gift: bool = False
|
||||
gift_recipient_type: str | None = Field(default=None, pattern=r'^(email|telegram)$')
|
||||
gift_recipient_value: str | None = Field(default=None, max_length=255)
|
||||
gift_message: str | None = Field(default=None, max_length=1000)
|
||||
yandex_cid: str | None = Field(default=None, max_length=128, pattern=r'^[A-Za-z0-9._:-]{4,128}$')
|
||||
referrer: str | None = Field(default=None, max_length=500)
|
||||
subid: str | None = Field(default=None, max_length=255)
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_contacts(self) -> 'PurchaseRequest':
|
||||
_validate_contact(self.contact_type, self.contact_value)
|
||||
if self.is_gift:
|
||||
if not self.gift_recipient_type or not self.gift_recipient_value:
|
||||
raise ValueError('Gift recipient type and value are required for gift purchases')
|
||||
_validate_contact(self.gift_recipient_type, self.gift_recipient_value)
|
||||
return self
|
||||
|
||||
|
||||
class PurchaseResponse(BaseModel):
|
||||
purchase_token: str
|
||||
payment_url: str
|
||||
|
||||
|
||||
class PurchaseStatusResponse(BaseModel):
|
||||
status: str
|
||||
subscription_url: str | None = None
|
||||
subscription_crypto_link: str | None = None
|
||||
is_gift: bool = False
|
||||
contact_value: str | None = None
|
||||
recipient_contact_value: str | None = None
|
||||
period_days: int | None = None
|
||||
tariff_name: str | None = None
|
||||
gift_message: str | None = None
|
||||
contact_type: str | None = None
|
||||
cabinet_email: str | None = None
|
||||
cabinet_password: str | None = None
|
||||
auto_login_token: str | None = None
|
||||
recipient_in_bot: bool | None = None
|
||||
bot_link: str | None = None
|
||||
|
||||
|
||||
# ============ Helpers ============
|
||||
|
||||
|
||||
def _mask_contact(value: str) -> str:
|
||||
"""Mask contact value to avoid leaking PII in API responses."""
|
||||
if '@' in value and not value.startswith('@'):
|
||||
# Email: show first 2 chars + mask + domain
|
||||
local, domain = value.rsplit('@', 1)
|
||||
return f'{local[:2]}***@{domain}'
|
||||
if value.startswith('@'):
|
||||
# Telegram: show first 3 chars + mask
|
||||
return f'{value[:3]}***'
|
||||
return value[:3] + '***'
|
||||
|
||||
|
||||
_SUBSCRIPTION_URL_EXPIRY_HOURS = 24
|
||||
|
||||
|
||||
def _build_purchase_status_response(purchase: GuestPurchase) -> PurchaseStatusResponse:
|
||||
"""Build a PurchaseStatusResponse from a GuestPurchase record."""
|
||||
tariff_name = purchase.tariff.name if purchase.tariff else None
|
||||
|
||||
within_ttl = False
|
||||
subscription_url = None
|
||||
subscription_crypto_link = None
|
||||
if purchase.delivered_at and purchase.subscription_url and not purchase.is_gift:
|
||||
age = datetime.now(UTC) - purchase.delivered_at
|
||||
if age < timedelta(hours=_SUBSCRIPTION_URL_EXPIRY_HOURS):
|
||||
within_ttl = True
|
||||
subscription_url = purchase.subscription_url
|
||||
subscription_crypto_link = purchase.subscription_crypto_link
|
||||
|
||||
masked_contact = _mask_contact(purchase.contact_value) if purchase.contact_value else None
|
||||
|
||||
recipient_contact_value = None
|
||||
gift_message = None
|
||||
if purchase.is_gift:
|
||||
if purchase.gift_recipient_value:
|
||||
recipient_contact_value = _mask_contact(purchase.gift_recipient_value)
|
||||
gift_message = purchase.gift_message
|
||||
|
||||
# Determine effective contact type for the recipient
|
||||
if purchase.is_gift and purchase.gift_recipient_type:
|
||||
effective_contact_type = purchase.gift_recipient_type
|
||||
else:
|
||||
effective_contact_type = purchase.contact_type
|
||||
|
||||
# Cabinet credentials for email self-purchases (not gifts)
|
||||
cabinet_email = None
|
||||
cabinet_password = None
|
||||
auto_login_token = None
|
||||
is_terminal = purchase.status in (GuestPurchaseStatus.DELIVERED.value, GuestPurchaseStatus.PENDING_ACTIVATION.value)
|
||||
is_email_self_purchase = effective_contact_type == 'email' and not purchase.is_gift
|
||||
|
||||
if is_terminal and is_email_self_purchase:
|
||||
cabinet_email = purchase.contact_value
|
||||
# For PENDING_ACTIVATION: cap credential exposure at 72h from paid_at
|
||||
pending_within_ttl = (
|
||||
purchase.status == GuestPurchaseStatus.PENDING_ACTIVATION.value
|
||||
and purchase.paid_at
|
||||
and (datetime.now(UTC) - purchase.paid_at) < timedelta(hours=72)
|
||||
)
|
||||
if within_ttl or pending_within_ttl:
|
||||
cabinet_password = purchase.cabinet_password
|
||||
auto_login_token = purchase.auto_login_token
|
||||
|
||||
# For telegram gifts: indicate whether recipient is known to the bot
|
||||
recipient_in_bot: bool | None = None
|
||||
bot_link: str | None = None
|
||||
if purchase.is_gift and effective_contact_type == 'telegram':
|
||||
recipient_in_bot = purchase.user is not None and purchase.user.telegram_id is not None
|
||||
if not recipient_in_bot:
|
||||
bot_username = settings.get_bot_username()
|
||||
if bot_username:
|
||||
bot_link = f'https://t.me/{bot_username}'
|
||||
|
||||
return PurchaseStatusResponse(
|
||||
status=purchase.status,
|
||||
subscription_url=subscription_url,
|
||||
subscription_crypto_link=subscription_crypto_link,
|
||||
is_gift=purchase.is_gift,
|
||||
contact_value=masked_contact,
|
||||
recipient_contact_value=recipient_contact_value,
|
||||
period_days=purchase.period_days,
|
||||
tariff_name=tariff_name,
|
||||
gift_message=gift_message,
|
||||
contact_type=effective_contact_type,
|
||||
cabinet_email=cabinet_email,
|
||||
cabinet_password=cabinet_password,
|
||||
auto_login_token=auto_login_token,
|
||||
recipient_in_bot=recipient_in_bot,
|
||||
bot_link=bot_link,
|
||||
)
|
||||
|
||||
|
||||
def _period_label(days: int) -> str:
|
||||
"""Human-readable label for a period in days."""
|
||||
if days == 1:
|
||||
return '1 day'
|
||||
if days <= 6:
|
||||
return f'{days} days'
|
||||
if days == 7:
|
||||
return '1 week'
|
||||
if days == 14:
|
||||
return '2 weeks'
|
||||
if days == 30:
|
||||
return '1 month'
|
||||
if days == 60:
|
||||
return '2 months'
|
||||
if days == 90:
|
||||
return '3 months'
|
||||
if days == 180:
|
||||
return '6 months'
|
||||
if days == 365:
|
||||
return '1 year'
|
||||
if days == 456:
|
||||
return '1 year + 3 mo.'
|
||||
|
||||
months = days // 30
|
||||
remainder = days % 30
|
||||
if months > 0 and remainder == 0:
|
||||
return f'{months} mo.'
|
||||
if months > 0:
|
||||
return f'{months} mo. + {remainder} d.'
|
||||
return f'{days} days'
|
||||
|
||||
|
||||
def _get_active_discount(landing: LandingPage, lang: str) -> LandingDiscountInfo | None:
|
||||
"""Return discount info if currently active, else None."""
|
||||
if not landing.discount_percent or not landing.discount_starts_at or not landing.discount_ends_at:
|
||||
return None
|
||||
now = datetime.now(UTC)
|
||||
if not (landing.discount_starts_at <= now < landing.discount_ends_at):
|
||||
return None
|
||||
badge = resolve_locale_text(landing.discount_badge_text, lang) if landing.discount_badge_text else None
|
||||
return LandingDiscountInfo(
|
||||
percent=landing.discount_percent,
|
||||
ends_at=landing.discount_ends_at.isoformat(),
|
||||
badge_text=badge or None,
|
||||
)
|
||||
|
||||
|
||||
async def _load_landing_tariffs(
|
||||
db: AsyncSession, landing: LandingPage, discount: LandingDiscountInfo | None = None
|
||||
) -> list[LandingTariff]:
|
||||
"""Load tariffs for a landing page, filtered by allowed IDs and periods."""
|
||||
allowed_ids = landing.allowed_tariff_ids or []
|
||||
if not allowed_ids:
|
||||
return []
|
||||
|
||||
result = await db.execute(
|
||||
select(Tariff)
|
||||
.where(Tariff.id.in_(allowed_ids), Tariff.is_active.is_(True))
|
||||
.order_by(Tariff.display_order, Tariff.id)
|
||||
)
|
||||
tariffs = result.scalars().all()
|
||||
|
||||
allowed_periods = landing.allowed_periods or {}
|
||||
landing_tariffs = []
|
||||
|
||||
for tariff in tariffs:
|
||||
# Determine which periods to show
|
||||
tariff_period_override = allowed_periods.get(str(tariff.id))
|
||||
if tariff_period_override is not None:
|
||||
period_days_list = sorted(tariff_period_override)
|
||||
else:
|
||||
period_days_list = tariff.get_available_periods()
|
||||
|
||||
periods = []
|
||||
for days in period_days_list:
|
||||
price = tariff.get_price_for_period(days)
|
||||
if price is None:
|
||||
continue
|
||||
|
||||
original_price_kopeks = None
|
||||
original_price_label = None
|
||||
effective_discount = None
|
||||
|
||||
if discount:
|
||||
# Per-tariff override takes priority (read from landing model, not response DTO)
|
||||
overrides = landing.discount_overrides or {}
|
||||
tariff_override = overrides.get(str(tariff.id))
|
||||
effective_discount = tariff_override if tariff_override is not None else discount.percent
|
||||
original_price_kopeks = price
|
||||
original_price_label = settings.format_price(price)
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
price = max(1, PricingEngine.apply_discount(price, effective_discount))
|
||||
|
||||
periods.append(
|
||||
LandingTariffPeriod(
|
||||
days=days,
|
||||
label=_period_label(days),
|
||||
price_kopeks=price,
|
||||
price_label=settings.format_price(price),
|
||||
original_price_kopeks=original_price_kopeks,
|
||||
original_price_label=original_price_label,
|
||||
discount_percent=effective_discount,
|
||||
)
|
||||
)
|
||||
|
||||
if not periods:
|
||||
continue
|
||||
|
||||
landing_tariffs.append(
|
||||
LandingTariff(
|
||||
id=tariff.id,
|
||||
name=tariff.name,
|
||||
description=tariff.description,
|
||||
traffic_limit_gb=tariff.traffic_limit_gb,
|
||||
device_limit=tariff.device_limit,
|
||||
tier_level=tariff.tier_level,
|
||||
periods=periods,
|
||||
)
|
||||
)
|
||||
|
||||
return landing_tariffs
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
# IMPORTANT: /purchase/{token} must come BEFORE /{slug} to avoid shadowing
|
||||
# (FastAPI checks routes in definition order; "purchase" would match {slug})
|
||||
|
||||
|
||||
@router.get('/purchase/{token}', response_model=PurchaseStatusResponse)
|
||||
async def get_purchase_status(
|
||||
token: str,
|
||||
raw_request: Request,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get the status of a guest purchase by token.
|
||||
|
||||
No authentication required.
|
||||
"""
|
||||
client_ip = get_client_ip(raw_request)
|
||||
if await RateLimitCache.is_ip_rate_limited(client_ip, 'purchase_status', limit=30, window=60, fail_closed=True):
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
|
||||
|
||||
purchase = await get_purchase_by_token(db, token)
|
||||
if purchase is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Purchase not found',
|
||||
)
|
||||
|
||||
response = _build_purchase_status_response(purchase)
|
||||
|
||||
# Cleanup: null expired credentials from DB
|
||||
needs_cleanup = False
|
||||
if purchase.delivered_at and (purchase.cabinet_password or purchase.auto_login_token):
|
||||
age = datetime.now(UTC) - purchase.delivered_at
|
||||
if age >= timedelta(hours=_SUBSCRIPTION_URL_EXPIRY_HOURS):
|
||||
needs_cleanup = True
|
||||
elif (
|
||||
purchase.status == GuestPurchaseStatus.PENDING_ACTIVATION.value
|
||||
and purchase.paid_at
|
||||
and (purchase.cabinet_password or purchase.auto_login_token)
|
||||
and (datetime.now(UTC) - purchase.paid_at) >= timedelta(hours=72)
|
||||
):
|
||||
needs_cleanup = True
|
||||
|
||||
if needs_cleanup:
|
||||
purchase.cabinet_password = None
|
||||
purchase.auto_login_token = None
|
||||
await db.commit()
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.post('/activate/{token}', response_model=PurchaseStatusResponse)
|
||||
async def activate_purchase(
|
||||
token: str,
|
||||
raw_request: Request,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Activate a pending guest purchase, replacing the user's current subscription.
|
||||
|
||||
No authentication required (token is the secret).
|
||||
"""
|
||||
client_ip = get_client_ip(raw_request)
|
||||
if await RateLimitCache.is_ip_rate_limited(client_ip, 'activate_purchase', limit=5, window=60, fail_closed=True):
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
|
||||
|
||||
try:
|
||||
purchase = await activate_guest_purchase(db, token)
|
||||
except GuestPurchaseError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
|
||||
|
||||
return _build_purchase_status_response(purchase)
|
||||
|
||||
|
||||
@router.get('/{slug}', response_model=LandingConfigResponse)
|
||||
async def get_landing_config(
|
||||
raw_request: Request,
|
||||
slug: str = Path(max_length=100),
|
||||
lang: str = Query(DEFAULT_LOCALE, max_length=5, description='Locale: ru, en, zh, fa'),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get public landing page configuration with tariffs and payment methods.
|
||||
|
||||
No authentication required. Pass ``?lang=en`` to get localized text.
|
||||
"""
|
||||
client_ip = get_client_ip(raw_request)
|
||||
if await RateLimitCache.is_ip_rate_limited(client_ip, 'landing_config', limit=60, window=60, fail_closed=True):
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
|
||||
|
||||
landing = await get_active_landing_by_slug(db, slug)
|
||||
if landing is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Landing page not found',
|
||||
)
|
||||
|
||||
discount = _get_active_discount(landing, lang)
|
||||
tariffs = await _load_landing_tariffs(db, landing, discount)
|
||||
|
||||
# Build payment methods from landing config
|
||||
raw_methods = landing.payment_methods or []
|
||||
method_defaults = _get_method_defaults()
|
||||
|
||||
payment_methods: list[LandingPaymentMethod] = []
|
||||
for m in raw_methods:
|
||||
method_id = m.get('method_id', '')
|
||||
raw_sub_options = m.get('sub_options') # dict[str, bool] | None
|
||||
|
||||
# Resolve sub-options: filter enabled ones and attach display names
|
||||
resolved_sub_options: list[LandingPaymentMethodSubOption] | None = None
|
||||
method_def = method_defaults.get(method_id)
|
||||
available = method_def.get('available_sub_options') if method_def else None
|
||||
if available:
|
||||
resolved = []
|
||||
for opt in available:
|
||||
opt_id = opt['id']
|
||||
# If landing has explicit sub_options config, respect it; otherwise all enabled
|
||||
if raw_sub_options is None or raw_sub_options.get(opt_id, True):
|
||||
resolved.append(LandingPaymentMethodSubOption(id=opt_id, name=opt['name']))
|
||||
if resolved:
|
||||
resolved_sub_options = resolved
|
||||
|
||||
payment_methods.append(
|
||||
LandingPaymentMethod(
|
||||
method_id=method_id,
|
||||
display_name=m.get('display_name', ''),
|
||||
description=m.get('description'),
|
||||
icon_url=m.get('icon_url'),
|
||||
sort_order=m.get('sort_order', 0),
|
||||
min_amount_kopeks=m.get('min_amount_kopeks'),
|
||||
max_amount_kopeks=m.get('max_amount_kopeks'),
|
||||
currency=m.get('currency'),
|
||||
sub_options=resolved_sub_options,
|
||||
)
|
||||
)
|
||||
|
||||
# Resolve locale dicts to flat strings for the requested language
|
||||
features = [
|
||||
LandingFeature(
|
||||
icon=f.get('icon', ''),
|
||||
title=resolve_locale_text(f.get('title'), lang),
|
||||
description=resolve_locale_text(f.get('description'), lang),
|
||||
)
|
||||
for f in (landing.features or [])
|
||||
]
|
||||
|
||||
return LandingConfigResponse(
|
||||
slug=landing.slug,
|
||||
title=resolve_locale_text(landing.title, lang),
|
||||
subtitle=resolve_locale_text(landing.subtitle, lang) or None,
|
||||
features=features,
|
||||
footer_text=resolve_locale_text(landing.footer_text, lang) or None,
|
||||
tariffs=tariffs,
|
||||
payment_methods=payment_methods,
|
||||
gift_enabled=landing.gift_enabled,
|
||||
custom_css=landing.custom_css,
|
||||
meta_title=resolve_locale_text(landing.meta_title, lang) or None,
|
||||
meta_description=resolve_locale_text(landing.meta_description, lang) or None,
|
||||
discount=discount,
|
||||
background_config=landing.background_config,
|
||||
sticky_pay_button=landing.sticky_pay_button,
|
||||
analytics_view_enabled=landing.analytics_view_enabled,
|
||||
analytics_view_goal=landing.analytics_view_goal,
|
||||
analytics_click_enabled=landing.analytics_click_enabled,
|
||||
analytics_click_goal=landing.analytics_click_goal,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{slug}/purchase', response_model=PurchaseResponse)
|
||||
async def create_landing_purchase(
|
||||
slug: str,
|
||||
body: PurchaseRequest,
|
||||
raw_request: Request,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Create a guest purchase on a landing page.
|
||||
|
||||
No authentication required.
|
||||
"""
|
||||
client_ip = get_client_ip(raw_request)
|
||||
if await RateLimitCache.is_ip_rate_limited(client_ip, 'landing_purchase', limit=30, window=60, fail_closed=True):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail='Too many purchase attempts, please try again later',
|
||||
)
|
||||
|
||||
landing = await get_active_landing_by_slug(db, slug)
|
||||
if landing is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Landing page not found',
|
||||
)
|
||||
|
||||
if body.is_gift and not landing.gift_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Gift purchases are not enabled for this landing page',
|
||||
)
|
||||
|
||||
# Validate payment method is available on this landing.
|
||||
# The frontend may send a suffixed method ID (e.g. "platega_2", "yookassa_sbp")
|
||||
# to select a specific sub-option. We match against the base method_id and
|
||||
# validate the suffix against known & enabled sub-options.
|
||||
raw_methods = landing.payment_methods or []
|
||||
method_defaults = _get_method_defaults()
|
||||
|
||||
method_config = next((m for m in raw_methods if m.get('method_id') == body.payment_method), None)
|
||||
if method_config is None:
|
||||
# Try matching by prefix: "platega_2" → base "platega"
|
||||
# Sort by length descending so "freekassa_sbp" is checked before "freekassa"
|
||||
sorted_methods = sorted(raw_methods, key=lambda m: len(m.get('method_id', '')), reverse=True)
|
||||
for m in sorted_methods:
|
||||
mid = m.get('method_id', '')
|
||||
if body.payment_method.startswith(mid + '_'):
|
||||
suffix = body.payment_method[len(mid) + 1 :]
|
||||
# Validate suffix is a known sub-option
|
||||
method_def = method_defaults.get(mid)
|
||||
available = (method_def.get('available_sub_options') if method_def else None) or []
|
||||
valid_ids = {opt['id'] for opt in available}
|
||||
if suffix not in valid_ids:
|
||||
break # invalid suffix → reject
|
||||
# Validate suffix is enabled on this landing
|
||||
raw_sub_options = m.get('sub_options') # dict[str, bool] | None
|
||||
if raw_sub_options is not None and not raw_sub_options.get(suffix, True):
|
||||
break # disabled sub-option → reject
|
||||
method_config = m
|
||||
break
|
||||
if method_config is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Payment method is not available on this landing page',
|
||||
)
|
||||
|
||||
# Validate tariff + period + calculate price
|
||||
try:
|
||||
tariff, amount_kopeks = await validate_and_calculate(db, landing, body.tariff_id, body.period_days)
|
||||
except GuestPurchaseError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
|
||||
|
||||
# Gift purchases require the tariff to be visible in the gift section
|
||||
if body.is_gift and not tariff.show_in_gift:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='This tariff is not available for gift purchases',
|
||||
)
|
||||
|
||||
# Validate amount against per-method min/max limits (before creating purchase record)
|
||||
min_amount = method_config.get('min_amount_kopeks')
|
||||
max_amount = method_config.get('max_amount_kopeks')
|
||||
if min_amount is not None and amount_kopeks < min_amount:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Amount is below the minimum ({settings.format_price(min_amount)}) for this payment method',
|
||||
)
|
||||
if max_amount is not None and amount_kopeks > max_amount:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Amount exceeds the maximum ({settings.format_price(max_amount)}) for this payment method',
|
||||
)
|
||||
|
||||
# Create purchase record (no commit yet — wait for payment creation)
|
||||
purchase = await create_purchase(
|
||||
db,
|
||||
landing=landing,
|
||||
tariff=tariff,
|
||||
period_days=body.period_days,
|
||||
amount_kopeks=amount_kopeks,
|
||||
contact_type=body.contact_type,
|
||||
contact_value=body.contact_value,
|
||||
payment_method=body.payment_method,
|
||||
is_gift=body.is_gift,
|
||||
gift_recipient_type=body.gift_recipient_type,
|
||||
gift_recipient_value=body.gift_recipient_value,
|
||||
gift_message=body.gift_message,
|
||||
subid=body.subid,
|
||||
referrer=body.referrer,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
# Fallback to HTTP Referer header if body did not supply one
|
||||
if not purchase.referrer:
|
||||
http_referrer = raw_request.headers.get('referer') or raw_request.headers.get('referrer')
|
||||
if http_referrer and len(http_referrer) <= 500:
|
||||
purchase.referrer = http_referrer
|
||||
|
||||
# Determine return URL: per-method override → default cabinet URL
|
||||
cabinet_base = (settings.CABINET_URL or '').rstrip('/')
|
||||
default_return_url = f'{cabinet_base}/buy/success/{purchase.token}'
|
||||
method_return_url = method_config.get('return_url')
|
||||
if method_return_url:
|
||||
# Allow {token} placeholder in custom return URLs
|
||||
return_url = method_return_url.replace('{token}', purchase.token)
|
||||
else:
|
||||
return_url = default_return_url
|
||||
|
||||
payment_service = PaymentService()
|
||||
payment_result = await payment_service.create_guest_payment(
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
payment_method=body.payment_method,
|
||||
description=f'{tariff.name} — {body.period_days}d',
|
||||
purchase_token=purchase.token,
|
||||
return_url=return_url,
|
||||
)
|
||||
|
||||
if payment_result is None:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail='Payment provider is unavailable, please try again later',
|
||||
)
|
||||
|
||||
payment_url = payment_result.get('payment_url')
|
||||
if not payment_url:
|
||||
await db.rollback()
|
||||
logger.error(
|
||||
'Payment created but no payment_url returned',
|
||||
purchase_token=purchase.token[:5],
|
||||
provider=payment_result.get('provider'),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail='Payment provider returned an invalid response',
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(purchase)
|
||||
|
||||
# Persist Yandex CID in cache so fulfill_purchase can link it to the user later
|
||||
if body.yandex_cid and settings.YANDEX_OFFLINE_CONV_ENABLED:
|
||||
try:
|
||||
await cache.set(f'yacid:purchase:{purchase.token}', body.yandex_cid, expire=86400)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Persist subid in cache for S2S postback
|
||||
if body.subid:
|
||||
try:
|
||||
await cache.set(f'subid:purchase:{purchase.token}', body.subid, expire=86400)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return PurchaseResponse(
|
||||
purchase_token=purchase.token,
|
||||
payment_url=payment_url,
|
||||
)
|
||||
+11
-13
@@ -3,11 +3,13 @@
|
||||
import mimetypes
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.types import BufferedInputFile
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
|
||||
@@ -96,38 +98,31 @@ async def upload_media(
|
||||
target_chat_id = _resolve_target_chat_id()
|
||||
upload = BufferedInputFile(file_bytes, filename=file.filename or 'upload')
|
||||
|
||||
bot = create_bot()
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
|
||||
try:
|
||||
# Send with disable_notification to avoid pinging admins — this is just staging
|
||||
if media_type_normalized == 'photo':
|
||||
message = await bot.send_photo(
|
||||
chat_id=target_chat_id,
|
||||
photo=upload,
|
||||
disable_notification=True,
|
||||
)
|
||||
media = message.photo[-1]
|
||||
elif media_type_normalized == 'video':
|
||||
message = await bot.send_video(
|
||||
chat_id=target_chat_id,
|
||||
video=upload,
|
||||
disable_notification=True,
|
||||
)
|
||||
media = message.video
|
||||
else:
|
||||
message = await bot.send_document(
|
||||
chat_id=target_chat_id,
|
||||
document=upload,
|
||||
disable_notification=True,
|
||||
)
|
||||
media = message.document
|
||||
|
||||
# Delete the staging message immediately — file_id persists after deletion
|
||||
try:
|
||||
await bot.delete_message(chat_id=target_chat_id, message_id=message.message_id)
|
||||
except Exception:
|
||||
pass # Best-effort cleanup — file_id is already captured
|
||||
|
||||
media_url = _build_media_url(request, media.file_id)
|
||||
|
||||
logger.info(
|
||||
@@ -163,7 +158,10 @@ async def download_media(
|
||||
Download media file by file_id.
|
||||
Used to display images/documents in ticket messages.
|
||||
"""
|
||||
bot = create_bot()
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
|
||||
try:
|
||||
file = await bot.get_file(file_id)
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
"""Public news routes for cabinet - user-facing news/blog section."""
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.news import (
|
||||
get_news_article_by_slug,
|
||||
get_news_categories,
|
||||
get_published_news,
|
||||
get_published_news_count,
|
||||
increment_views,
|
||||
)
|
||||
from app.database.models import NewsArticle, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.news import (
|
||||
NewsArticleListItem,
|
||||
NewsArticleResponse,
|
||||
NewsListResponse,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Slug constraint: alphanumeric, hyphens, underscores, max 500 chars
|
||||
_SLUG_MAX_LENGTH: int = 500
|
||||
_SLUG_PATTERN: str = r'^[a-zA-Z0-9_-]+$'
|
||||
|
||||
# --- View counter deduplication ---
|
||||
# In-memory TTL cache to prevent a single user from inflating view counts.
|
||||
# Key: (user_id, article_id), Value: timestamp of last counted view.
|
||||
# Views from the same user on the same article within _VIEW_DEDUP_SECONDS are ignored.
|
||||
_VIEW_DEDUP_SECONDS: int = 300 # 5 minutes
|
||||
_VIEW_DEDUP_MAX_SIZE: int = 10_000 # max entries before eviction
|
||||
_view_dedup_cache: dict[tuple[int, int], float] = {}
|
||||
|
||||
|
||||
def _should_count_view(user_id: int, article_id: int) -> bool:
|
||||
"""Return True if this view should be counted (not a duplicate within TTL)."""
|
||||
now = time.monotonic()
|
||||
key = (user_id, article_id)
|
||||
last_seen = _view_dedup_cache.get(key)
|
||||
|
||||
if last_seen is not None and (now - last_seen) < _VIEW_DEDUP_SECONDS:
|
||||
return False
|
||||
|
||||
# Evict stale entries if cache grows too large
|
||||
if len(_view_dedup_cache) >= _VIEW_DEDUP_MAX_SIZE:
|
||||
cutoff = now - _VIEW_DEDUP_SECONDS
|
||||
stale_keys = [k for k, v in _view_dedup_cache.items() if v < cutoff]
|
||||
for k in stale_keys:
|
||||
del _view_dedup_cache[k]
|
||||
|
||||
_view_dedup_cache[key] = now
|
||||
return True
|
||||
|
||||
|
||||
router = APIRouter(prefix='/news', tags=['Cabinet News'])
|
||||
|
||||
|
||||
def _article_to_response(article: NewsArticle, *, include_content: bool = True) -> dict[str, Any]:
|
||||
"""Convert NewsArticle ORM instance to response dict.
|
||||
|
||||
``author_name`` is only resolved when ``include_content=True`` (single-article
|
||||
detail view) because the author relationship is not eagerly loaded for list
|
||||
queries -- accessing it there would trigger a lazy-load or raise
|
||||
``MissingGreenlet`` in async context.
|
||||
"""
|
||||
data: dict[str, Any] = {
|
||||
'id': article.id,
|
||||
'title': article.title,
|
||||
'slug': article.slug,
|
||||
'excerpt': article.excerpt,
|
||||
'category': article.category,
|
||||
'category_color': article.category_color,
|
||||
'tag': article.tag,
|
||||
'featured_image_url': article.featured_image_url,
|
||||
'is_published': article.is_published,
|
||||
'is_featured': article.is_featured,
|
||||
'published_at': article.published_at,
|
||||
'read_time_minutes': article.read_time_minutes,
|
||||
'views_count': article.views_count,
|
||||
}
|
||||
|
||||
if include_content:
|
||||
author_name: str | None = None
|
||||
if article.author:
|
||||
author_name = article.author.first_name or article.author.username or f'#{article.author.id}'
|
||||
data['content'] = article.content
|
||||
data['author_name'] = author_name
|
||||
data['created_at'] = article.created_at
|
||||
data['updated_at'] = article.updated_at
|
||||
|
||||
return data
|
||||
|
||||
|
||||
# NOTE: /categories MUST be declared before /{slug} to avoid route conflict
|
||||
@router.get('/categories', response_model=list[str])
|
||||
async def list_categories(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> list[str]:
|
||||
"""Get list of distinct news categories."""
|
||||
try:
|
||||
return await get_news_categories(db)
|
||||
except Exception:
|
||||
logger.exception('Failed to get news categories')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load categories',
|
||||
)
|
||||
|
||||
|
||||
@router.get('', response_model=NewsListResponse)
|
||||
async def list_published_news(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
category: str | None = Query(None, max_length=100),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> NewsListResponse:
|
||||
"""Get paginated list of published news articles.
|
||||
|
||||
SQLAlchemy AsyncSession does not support concurrent operations, so
|
||||
queries run sequentially.
|
||||
"""
|
||||
try:
|
||||
articles = await get_published_news(db, category=category, limit=limit, offset=offset)
|
||||
total = await get_published_news_count(db, category=category)
|
||||
categories = await get_news_categories(db)
|
||||
|
||||
items = [NewsArticleListItem(**_article_to_response(a, include_content=False)) for a in articles]
|
||||
|
||||
return NewsListResponse(items=items, total=total, categories=categories)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception('Failed to list published news')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load news',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{slug}', response_model=NewsArticleResponse)
|
||||
async def get_article_by_slug(
|
||||
slug: str = Path(..., max_length=_SLUG_MAX_LENGTH, pattern=_SLUG_PATTERN),
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsArticleResponse:
|
||||
"""Get a single published news article by slug. Increments view count."""
|
||||
article = await get_news_article_by_slug(db, slug)
|
||||
|
||||
if not article or not article.is_published:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Article not found',
|
||||
)
|
||||
|
||||
# Build response dict while session attributes are still loaded.
|
||||
# increment_views() calls db.commit() which expires all ORM attributes;
|
||||
# accessing them afterwards triggers lazy-load → MissingGreenlet in async.
|
||||
response_data = _article_to_response(article, include_content=True)
|
||||
|
||||
# Increment views with per-user deduplication (5-min TTL).
|
||||
if _should_count_view(user.id, article.id):
|
||||
try:
|
||||
new_count = await increment_views(db, article.id)
|
||||
response_data['views_count'] = new_count
|
||||
except Exception:
|
||||
logger.warning('Failed to increment views', article_id=article.id)
|
||||
|
||||
return NewsArticleResponse(**response_data)
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Notification settings routes for cabinet."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
@@ -28,7 +28,7 @@ class NotificationSettingsResponse(BaseModel):
|
||||
subscription_expiry_days: int = 3
|
||||
traffic_warning_enabled: bool = True
|
||||
traffic_warning_percent: int = 80
|
||||
balance_low_enabled: bool = False
|
||||
balance_low_enabled: bool = True
|
||||
balance_low_threshold: int = 100 # kopeks
|
||||
news_enabled: bool = True
|
||||
promo_offers_enabled: bool = True
|
||||
@@ -60,7 +60,7 @@ def _get_notification_settings(user: User) -> dict[str, Any]:
|
||||
'subscription_expiry_days': settings_data.get('subscription_expiry_days', 3),
|
||||
'traffic_warning_enabled': settings_data.get('traffic_warning_enabled', True),
|
||||
'traffic_warning_percent': settings_data.get('traffic_warning_percent', 80),
|
||||
'balance_low_enabled': settings_data.get('balance_low_enabled', False),
|
||||
'balance_low_enabled': settings_data.get('balance_low_enabled', True),
|
||||
'balance_low_threshold': settings_data.get('balance_low_threshold', 100),
|
||||
'news_enabled': settings_data.get('news_enabled', True),
|
||||
'promo_offers_enabled': settings_data.get('promo_offers_enabled', True),
|
||||
@@ -112,7 +112,7 @@ async def update_notification_settings(
|
||||
user.notification_settings = {}
|
||||
|
||||
user.notification_settings = new_settings
|
||||
user.updated_at = datetime.now(UTC)
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
+25
-112
@@ -12,7 +12,6 @@ from app.database.crud.user import (
|
||||
create_user_by_oauth,
|
||||
get_user_by_email,
|
||||
get_user_by_oauth_provider,
|
||||
get_user_by_referral_code,
|
||||
set_user_oauth_provider_id,
|
||||
)
|
||||
from app.database.models import User
|
||||
@@ -24,9 +23,8 @@ from ..auth.oauth_providers import (
|
||||
validate_oauth_state,
|
||||
)
|
||||
from ..dependencies import get_cabinet_db
|
||||
from ..routes.account_linking import OAuthProviderName
|
||||
from ..schemas.auth import AuthResponse
|
||||
from .auth import _create_auth_response, _process_campaign_bonus, _store_refresh_token
|
||||
from .auth import _create_auth_response, _store_refresh_token
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -34,29 +32,12 @@ logger = structlog.get_logger(__name__)
|
||||
router = APIRouter(prefix='/auth/oauth', tags=['Cabinet OAuth'])
|
||||
|
||||
|
||||
async def _finalize_oauth_login(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
provider: str,
|
||||
campaign_slug: str | None = None,
|
||||
referral_code: str | None = None,
|
||||
*,
|
||||
is_new_user: bool = False,
|
||||
) -> AuthResponse:
|
||||
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)
|
||||
user.cabinet_last_login = datetime.now(UTC).replace(tzinfo=None)
|
||||
await db.commit()
|
||||
auth_response = await _create_auth_response(user, db)
|
||||
auth_response = _create_auth_response(user)
|
||||
await _store_refresh_token(db, user.id, auth_response.refresh_token, device_info=f'oauth:{provider}')
|
||||
|
||||
# Process referral code (only for new users — existing users cannot be assigned a referrer)
|
||||
from .auth import _process_referral_code, _user_to_response
|
||||
|
||||
await _process_referral_code(db, user, referral_code, is_new_user=is_new_user)
|
||||
|
||||
auth_response.campaign_bonus = await _process_campaign_bonus(db, user, campaign_slug)
|
||||
if auth_response.campaign_bonus:
|
||||
auth_response.user = _user_to_response(user)
|
||||
return auth_response
|
||||
|
||||
|
||||
@@ -78,15 +59,8 @@ class OAuthAuthorizeResponse(BaseModel):
|
||||
|
||||
|
||||
class OAuthCallbackRequest(BaseModel):
|
||||
code: str = Field(..., min_length=1, max_length=2048, description='Authorization code from provider')
|
||||
state: str = Field(..., min_length=1, max_length=128, description='CSRF state token')
|
||||
device_id: str | None = Field(None, max_length=256, description='Device ID from VK ID callback')
|
||||
campaign_slug: str | None = Field(
|
||||
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
|
||||
)
|
||||
referral_code: str | None = Field(
|
||||
None, max_length=32, pattern=r'^[a-zA-Z0-9_-]+$', description='Referral code of inviter'
|
||||
)
|
||||
code: str = Field(..., description='Authorization code from provider')
|
||||
state: str = Field(..., description='CSRF state token')
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
@@ -105,68 +79,48 @@ async def get_oauth_providers():
|
||||
|
||||
|
||||
@router.get('/{provider}/authorize', response_model=OAuthAuthorizeResponse)
|
||||
async def get_oauth_authorize_url(provider: OAuthProviderName):
|
||||
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='Requested OAuth provider is not available',
|
||||
detail=f'OAuth provider "{provider}" is not enabled',
|
||||
)
|
||||
|
||||
# Generate extra state data (e.g., PKCE code_verifier for VK)
|
||||
auth_extra = oauth_provider.prepare_auth_state()
|
||||
state = await generate_oauth_state(provider, extra_data=auth_extra or None)
|
||||
# Only pass URL-safe params (prefixed with _) to authorize URL; exclude secrets like code_verifier
|
||||
url_params = {k: v for k, v in auth_extra.items() if k.startswith('_')} if auth_extra else {}
|
||||
authorize_url = oauth_provider.get_authorization_url(state, **url_params)
|
||||
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: OAuthProviderName,
|
||||
provider: str,
|
||||
request: OAuthCallbackRequest,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Handle OAuth callback: exchange code, find/create user, return JWT."""
|
||||
# 1. Validate CSRF state and retrieve stored data (e.g., PKCE code_verifier)
|
||||
state_data = await validate_oauth_state(request.state, provider)
|
||||
if not state_data:
|
||||
# 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',
|
||||
)
|
||||
|
||||
# 1b. Reject linking-flow state tokens (must use link_provider_callback instead)
|
||||
if state_data.get('linking') == 'true':
|
||||
logger.warning('Linking-flow state token used in login callback', provider=provider)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='OAuth state was initiated for account linking, not login',
|
||||
)
|
||||
|
||||
# 2. Get provider instance
|
||||
oauth_provider = get_provider(provider)
|
||||
if not oauth_provider:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Requested OAuth provider is not available',
|
||||
detail=f'OAuth provider "{provider}" is not enabled',
|
||||
)
|
||||
|
||||
# 3. Exchange code for tokens (pass PKCE code_verifier and device_id if present)
|
||||
exchange_kwargs: dict[str, str] = {'state': request.state}
|
||||
code_verifier = state_data.get('code_verifier')
|
||||
if code_verifier:
|
||||
exchange_kwargs['code_verifier'] = code_verifier
|
||||
if request.device_id:
|
||||
exchange_kwargs['device_id'] = request.device_id
|
||||
|
||||
# 3. Exchange code for tokens
|
||||
try:
|
||||
token_data = await oauth_provider.exchange_code(request.code, **exchange_kwargs)
|
||||
token_data = await oauth_provider.exchange_code(request.code)
|
||||
except Exception as exc:
|
||||
logger.error('OAuth code exchange failed', provider=provider, exc_info=True)
|
||||
logger.error('OAuth code exchange failed for', provider=provider, exc=exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to exchange authorization code',
|
||||
@@ -176,7 +130,7 @@ async def oauth_callback(
|
||||
try:
|
||||
user_info: OAuthUserInfo = await oauth_provider.get_user_info(token_data)
|
||||
except Exception as exc:
|
||||
logger.error('OAuth user info fetch failed', provider=provider, exc_info=True)
|
||||
logger.error('OAuth user info fetch failed for', provider=provider, exc=exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to fetch user information from provider',
|
||||
@@ -185,43 +139,18 @@ async def oauth_callback(
|
||||
# 5. Find user by provider ID
|
||||
user = await get_user_by_oauth_provider(db, provider, user_info.provider_id)
|
||||
if user:
|
||||
logger.info('OAuth login for existing user', provider=provider, user_id=user.id)
|
||||
return await _finalize_oauth_login(db, user, provider, request.campaign_slug, request.referral_code)
|
||||
logger.info('OAuth login via for existing user', provider=provider, user_id=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 provider linked to existing email user', provider=provider, user_id=user.id)
|
||||
return await _finalize_oauth_login(db, user, provider, request.campaign_slug, request.referral_code)
|
||||
logger.info('OAuth login via linked to existing email user', provider=provider, user_id=user.id)
|
||||
return await _finalize_oauth_login(db, user, provider)
|
||||
|
||||
# 7. Resolve referral code for new user
|
||||
referrer_id = None
|
||||
if request.referral_code:
|
||||
try:
|
||||
referrer = await get_user_by_referral_code(db, request.referral_code)
|
||||
if referrer:
|
||||
# Self-referral protection by email
|
||||
if (
|
||||
user_info.email
|
||||
and user_info.email_verified
|
||||
and referrer.email
|
||||
and referrer.email.lower() == user_info.email.lower()
|
||||
):
|
||||
logger.warning(
|
||||
'Self-referral attempt blocked via OAuth',
|
||||
referral_code=request.referral_code,
|
||||
email=user_info.email,
|
||||
)
|
||||
else:
|
||||
referrer_id = referrer.id
|
||||
except Exception:
|
||||
logger.warning(
|
||||
'Failed to resolve referral code during OAuth', referral_code=request.referral_code, exc_info=True
|
||||
)
|
||||
|
||||
# 8. Create new user
|
||||
# 7. Create new user
|
||||
user = await create_user_by_oauth(
|
||||
db=db,
|
||||
provider=provider,
|
||||
@@ -231,22 +160,6 @@ async def oauth_callback(
|
||||
first_name=user_info.first_name,
|
||||
last_name=user_info.last_name,
|
||||
username=user_info.username,
|
||||
referred_by_id=referrer_id,
|
||||
)
|
||||
logger.info('New OAuth user created', provider=provider, user_id=user.id)
|
||||
|
||||
# Commit user before panel sync (sync does its own commit/rollback)
|
||||
await db.commit()
|
||||
|
||||
# Sync existing panel subscriptions by email (if verified)
|
||||
if user_info.email and user_info.email_verified:
|
||||
try:
|
||||
from app.cabinet.routes.auth import _sync_subscription_from_panel_by_email
|
||||
|
||||
await _sync_subscription_from_panel_by_email(db, user)
|
||||
except Exception:
|
||||
logger.warning('Failed to sync panel subscription for new OAuth user', user_id=user.id, exc_info=True)
|
||||
|
||||
return await _finalize_oauth_login(
|
||||
db, user, provider, request.campaign_slug, request.referral_code, is_new_user=True
|
||||
)
|
||||
logger.info('OAuth new user created via with id', provider=provider, user_id=user.id)
|
||||
return await _finalize_oauth_login(db, user, provider)
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
"""User-facing partner application routes for cabinet."""
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.cabinet.utils.links import get_campaign_deep_link, get_campaign_web_link
|
||||
from app.config import settings
|
||||
from app.database.models import AdvertisingCampaign, User
|
||||
from app.services.partner_application_service import partner_application_service
|
||||
from app.services.partner_stats_service import PartnerStatsService
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.partners import (
|
||||
CampaignReferralItem,
|
||||
DailyStatItem,
|
||||
PartnerApplicationInfo,
|
||||
PartnerApplicationRequest,
|
||||
PartnerCampaignDetailedStats,
|
||||
PartnerCampaignInfo,
|
||||
PartnerStatusResponse,
|
||||
PeriodChange,
|
||||
PeriodComparison,
|
||||
PeriodStats,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/referral/partner', tags=['Cabinet Partner'])
|
||||
|
||||
|
||||
@router.get('/status', response_model=PartnerStatusResponse)
|
||||
async def get_partner_status(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get partner status and latest application for current user."""
|
||||
latest_app = await partner_application_service.get_latest_application(db, user.id)
|
||||
|
||||
app_info = None
|
||||
if latest_app:
|
||||
app_info = PartnerApplicationInfo(
|
||||
id=latest_app.id,
|
||||
status=latest_app.status,
|
||||
company_name=latest_app.company_name,
|
||||
website_url=latest_app.website_url,
|
||||
telegram_channel=latest_app.telegram_channel,
|
||||
description=latest_app.description,
|
||||
expected_monthly_referrals=latest_app.expected_monthly_referrals,
|
||||
desired_commission_percent=latest_app.desired_commission_percent,
|
||||
admin_comment=latest_app.admin_comment,
|
||||
approved_commission_percent=latest_app.approved_commission_percent,
|
||||
created_at=latest_app.created_at,
|
||||
processed_at=latest_app.processed_at,
|
||||
)
|
||||
|
||||
commission = user.referral_commission_percent
|
||||
if commission is None and user.is_partner:
|
||||
commission = settings.REFERRAL_COMMISSION_PERCENT
|
||||
|
||||
# Fetch campaigns assigned to this partner
|
||||
campaigns: list[PartnerCampaignInfo] = []
|
||||
if user.is_partner:
|
||||
result = await db.execute(
|
||||
select(AdvertisingCampaign).where(
|
||||
AdvertisingCampaign.partner_user_id == user.id,
|
||||
AdvertisingCampaign.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
campaign_models = result.scalars().all()
|
||||
|
||||
# Fetch per-campaign stats in one batch
|
||||
campaign_ids = [c.id for c in campaign_models]
|
||||
campaign_stats = await PartnerStatsService.get_per_campaign_stats(db, user.id, campaign_ids)
|
||||
|
||||
for c in campaign_models:
|
||||
stats = campaign_stats.get(c.id, {})
|
||||
campaigns.append(
|
||||
PartnerCampaignInfo(
|
||||
id=c.id,
|
||||
name=c.name,
|
||||
start_parameter=c.start_parameter,
|
||||
bonus_type=c.bonus_type,
|
||||
balance_bonus_kopeks=c.balance_bonus_kopeks or 0,
|
||||
subscription_duration_days=c.subscription_duration_days,
|
||||
subscription_traffic_gb=c.subscription_traffic_gb,
|
||||
deep_link=get_campaign_deep_link(c.start_parameter),
|
||||
web_link=get_campaign_web_link(c.start_parameter),
|
||||
registrations_count=stats.get('registrations_count', 0),
|
||||
referrals_count=stats.get('referrals_count', 0),
|
||||
earnings_kopeks=stats.get('earnings_kopeks', 0),
|
||||
)
|
||||
)
|
||||
|
||||
return PartnerStatusResponse(
|
||||
partner_status=user.partner_status,
|
||||
commission_percent=commission,
|
||||
latest_application=app_info,
|
||||
campaigns=campaigns,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/campaigns/{campaign_id}/stats', response_model=PartnerCampaignDetailedStats)
|
||||
async def get_campaign_stats(
|
||||
campaign_id: int,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get detailed stats for a single campaign belonging to the current partner."""
|
||||
if not user.is_partner:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Partner status required',
|
||||
)
|
||||
|
||||
# Verify campaign belongs to this partner
|
||||
campaign_result = await db.execute(
|
||||
select(AdvertisingCampaign).where(
|
||||
AdvertisingCampaign.id == campaign_id,
|
||||
AdvertisingCampaign.partner_user_id == user.id,
|
||||
)
|
||||
)
|
||||
campaign = campaign_result.scalar_one_or_none()
|
||||
if not campaign:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Campaign not found or not assigned to you',
|
||||
)
|
||||
|
||||
raw = await PartnerStatsService.get_campaign_detailed_stats(db, user.id, campaign_id)
|
||||
|
||||
return PartnerCampaignDetailedStats(
|
||||
campaign_id=raw['campaign_id'],
|
||||
campaign_name=campaign.name,
|
||||
registrations_count=raw['registrations_count'],
|
||||
referrals_count=raw['referrals_count'],
|
||||
earnings_kopeks=raw['earnings_kopeks'],
|
||||
conversion_rate=raw['conversion_rate'],
|
||||
earnings_today=raw['earnings_today'],
|
||||
earnings_week=raw['earnings_week'],
|
||||
earnings_month=raw['earnings_month'],
|
||||
daily_stats=[DailyStatItem(**d) for d in raw['daily_stats']],
|
||||
period_comparison=PeriodComparison(
|
||||
current=PeriodStats(**raw['period_comparison']['current']),
|
||||
previous=PeriodStats(**raw['period_comparison']['previous']),
|
||||
referrals_change=PeriodChange(**raw['period_comparison']['referrals_change']),
|
||||
earnings_change=PeriodChange(**raw['period_comparison']['earnings_change']),
|
||||
),
|
||||
top_referrals=[CampaignReferralItem(**r) for r in raw['top_referrals']],
|
||||
)
|
||||
|
||||
|
||||
@router.post('/apply', response_model=PartnerApplicationInfo)
|
||||
async def apply_for_partner(
|
||||
request: PartnerApplicationRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Submit partner application."""
|
||||
application, error = await partner_application_service.submit_application(
|
||||
db,
|
||||
user_id=user.id,
|
||||
company_name=request.company_name,
|
||||
website_url=request.website_url,
|
||||
telegram_channel=request.telegram_channel,
|
||||
description=request.description,
|
||||
expected_monthly_referrals=request.expected_monthly_referrals,
|
||||
desired_commission_percent=request.desired_commission_percent,
|
||||
)
|
||||
|
||||
if not application:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error,
|
||||
)
|
||||
|
||||
# Уведомляем админов о новой заявке
|
||||
try:
|
||||
from app.bot_factory import create_bot
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = create_bot()
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_partner_application_notification(
|
||||
user=user,
|
||||
application_data={
|
||||
'company_name': request.company_name,
|
||||
'telegram_channel': request.telegram_channel,
|
||||
'website_url': request.website_url,
|
||||
'description': request.description,
|
||||
'expected_monthly_referrals': request.expected_monthly_referrals,
|
||||
'desired_commission_percent': request.desired_commission_percent,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error('Failed to send admin notification for partner application', error=e)
|
||||
|
||||
return PartnerApplicationInfo(
|
||||
id=application.id,
|
||||
status=application.status,
|
||||
company_name=application.company_name,
|
||||
website_url=application.website_url,
|
||||
telegram_channel=application.telegram_channel,
|
||||
description=application.description,
|
||||
expected_monthly_referrals=application.expected_monthly_referrals,
|
||||
desired_commission_percent=application.desired_commission_percent,
|
||||
admin_comment=application.admin_comment,
|
||||
approved_commission_percent=application.approved_commission_percent,
|
||||
created_at=application.created_at,
|
||||
processed_at=application.processed_at,
|
||||
)
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Polls routes for cabinet - user participation in polls/surveys."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
@@ -144,7 +144,7 @@ async def get_available_polls(
|
||||
selectinload(PollResponse.poll).selectinload(Poll.questions),
|
||||
selectinload(PollResponse.answers),
|
||||
)
|
||||
.order_by(PollResponse.sent_at.desc())
|
||||
.order_by(PollResponse.created_at.desc())
|
||||
)
|
||||
responses = result.scalars().all()
|
||||
|
||||
@@ -247,7 +247,7 @@ async def start_poll(
|
||||
|
||||
# Mark as started if not already
|
||||
if not response.started_at:
|
||||
response.started_at = datetime.now(UTC)
|
||||
response.started_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
# Get next unanswered question
|
||||
@@ -346,7 +346,7 @@ async def answer_question(
|
||||
)
|
||||
|
||||
# Poll completed
|
||||
response.completed_at = datetime.now(UTC)
|
||||
response.completed_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
# Award reward if any
|
||||
|
||||
+17
-22
@@ -1,6 +1,6 @@
|
||||
"""Promo offers routes for cabinet - personal discounts and offers."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
@@ -112,7 +112,7 @@ async def get_promo_offers(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of available promo offers for the user."""
|
||||
now = datetime.now(UTC)
|
||||
now = datetime.utcnow()
|
||||
|
||||
result = await db.execute(
|
||||
select(DiscountOffer)
|
||||
@@ -151,7 +151,7 @@ async def get_active_discount(
|
||||
expires_at = user.promo_offer_discount_expires_at
|
||||
source = user.promo_offer_discount_source
|
||||
|
||||
now = datetime.now(UTC)
|
||||
now = datetime.utcnow()
|
||||
is_active = discount_percent > 0 and (expires_at is None or expires_at > now)
|
||||
|
||||
return ActiveDiscountInfo(
|
||||
@@ -204,11 +204,15 @@ async def get_loyalty_tiers(
|
||||
total_spent_kopeks = await get_user_total_spent_kopeks(db, user.id)
|
||||
total_spent_rubles = total_spent_kopeks / 100
|
||||
|
||||
# Get user's current promo group
|
||||
await db.refresh(user, ['promo_group', 'user_promo_groups'])
|
||||
current_promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
|
||||
current_tier_name = current_promo_group.name if current_promo_group else None
|
||||
|
||||
# Get all auto-assign promo groups (sorted by threshold ascending)
|
||||
auto_groups = await get_auto_assign_promo_groups(db)
|
||||
|
||||
tiers: list[LoyaltyTierInfo] = []
|
||||
current_tier_name: str | None = None
|
||||
next_tier_name: str | None = None
|
||||
next_tier_threshold: float | None = None
|
||||
|
||||
@@ -216,15 +220,7 @@ async def get_loyalty_tiers(
|
||||
threshold_kopeks = group.auto_assign_total_spent_kopeks or 0
|
||||
threshold_rubles = threshold_kopeks / 100
|
||||
is_achieved = total_spent_kopeks >= threshold_kopeks
|
||||
|
||||
# Track highest achieved tier as "current" (by spending, not by assignment)
|
||||
if is_achieved:
|
||||
current_tier_name = group.name
|
||||
|
||||
# Find next tier (first not achieved)
|
||||
if not is_achieved and next_tier_name is None:
|
||||
next_tier_name = group.name
|
||||
next_tier_threshold = threshold_rubles
|
||||
is_current = current_promo_group and current_promo_group.id == group.id
|
||||
|
||||
# Get period discounts
|
||||
period_discounts = {}
|
||||
@@ -245,16 +241,15 @@ async def get_loyalty_tiers(
|
||||
traffic_discount_percent=group.traffic_discount_percent or 0,
|
||||
device_discount_percent=group.device_discount_percent or 0,
|
||||
period_discounts=period_discounts,
|
||||
is_current=False,
|
||||
is_current=is_current,
|
||||
is_achieved=is_achieved,
|
||||
)
|
||||
)
|
||||
|
||||
# Mark only the highest achieved tier as "current"
|
||||
for tier in reversed(tiers):
|
||||
if tier.is_achieved:
|
||||
tier.is_current = True
|
||||
break
|
||||
# Find next tier (first not achieved)
|
||||
if not is_achieved and next_tier_name is None:
|
||||
next_tier_name = group.name
|
||||
next_tier_threshold = threshold_rubles
|
||||
|
||||
# Calculate progress to next tier
|
||||
progress_percent = 0.0
|
||||
@@ -289,7 +284,7 @@ async def claim_promo_offer(
|
||||
detail='Offer not found',
|
||||
)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
now = datetime.utcnow()
|
||||
|
||||
if offer.claimed_at is not None:
|
||||
raise HTTPException(
|
||||
@@ -309,7 +304,7 @@ async def claim_promo_offer(
|
||||
|
||||
# Handle test access offers
|
||||
if effect_type == 'test_access':
|
||||
await db.refresh(user, ['subscriptions'])
|
||||
await db.refresh(user, ['subscription'])
|
||||
success, newly_added, expires_at, error_code = await promo_offer_service.grant_test_access(
|
||||
db,
|
||||
user,
|
||||
@@ -413,7 +408,7 @@ async def clear_active_discount(
|
||||
user.promo_offer_discount_percent = 0
|
||||
user.promo_offer_discount_source = None
|
||||
user.promo_offer_discount_expires_at = None
|
||||
user.updated_at = datetime.now(UTC)
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.services.promocode_service import PromoCodeService
|
||||
|
||||
@@ -21,7 +20,6 @@ class PromocodeActivateRequest(BaseModel):
|
||||
"""Request to activate a promo code."""
|
||||
|
||||
code: str = Field(..., min_length=1, max_length=50, description='Promo code to activate')
|
||||
subscription_id: int | None = Field(None, description='Subscription ID for multi-tariff promo codes')
|
||||
|
||||
|
||||
class PromocodeActivateResponse(BaseModel):
|
||||
@@ -43,7 +41,7 @@ class PromocodeDeactivateResponse(BaseModel):
|
||||
discount_percent: int = 0
|
||||
|
||||
|
||||
@router.post('/activate')
|
||||
@router.post('/activate', response_model=PromocodeActivateResponse)
|
||||
async def activate_promocode(
|
||||
request: PromocodeActivateRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
@@ -52,45 +50,12 @@ async def activate_promocode(
|
||||
"""Activate a promo code for the current user."""
|
||||
promocode_service = PromoCodeService()
|
||||
|
||||
result = await promocode_service.activate_promocode(
|
||||
db=db, user_id=user.id, code=request.code.strip(), subscription_id=request.subscription_id
|
||||
)
|
||||
|
||||
if result.get('error') == 'select_subscription':
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'select_subscription',
|
||||
'eligible_subscriptions': result.get('eligible_subscriptions', []),
|
||||
'code': result.get('code', request.code.strip()),
|
||||
}
|
||||
result = await promocode_service.activate_promocode(db=db, user_id=user.id, code=request.code.strip())
|
||||
|
||||
if result['success']:
|
||||
balance_before_rubles = result.get('balance_before_kopeks', 0) / 100
|
||||
balance_after_rubles = result.get('balance_after_kopeks', 0) / 100
|
||||
|
||||
# Send admin notification (same as bot handler)
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_promocode_activation_notification(
|
||||
db,
|
||||
user,
|
||||
result.get('promocode', {'code': request.code.strip()}),
|
||||
result.get('description', ''),
|
||||
result.get('balance_before_kopeks'),
|
||||
result.get('balance_after_kopeks'),
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return PromocodeActivateResponse(
|
||||
success=True,
|
||||
message='Promo code activated successfully',
|
||||
@@ -103,15 +68,10 @@ async def activate_promocode(
|
||||
error_messages = {
|
||||
'not_found': 'Promo code not found',
|
||||
'expired': 'Promo code has expired',
|
||||
'inactive': 'Promo code is deactivated',
|
||||
'not_yet_valid': 'Promo code is not yet active',
|
||||
'used': 'Promo code has been fully used',
|
||||
'already_used_by_user': 'You have already used this promo code',
|
||||
'active_discount_exists': 'You already have an active discount. Deactivate it first via /deactivate-discount',
|
||||
'no_subscription_for_days': 'This promo code requires an active or expired subscription',
|
||||
'subscription_not_found': 'Subscription not found',
|
||||
'not_first_purchase': 'This promo code is only available for first purchase',
|
||||
'daily_limit': 'Too many promo code activations today',
|
||||
'user_not_found': 'User not found',
|
||||
'server_error': 'Server error occurred',
|
||||
}
|
||||
|
||||
@@ -9,15 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import (
|
||||
AdvertisingCampaign,
|
||||
ReferralEarning,
|
||||
Subscription,
|
||||
SubscriptionStatus,
|
||||
User,
|
||||
WithdrawalRequest,
|
||||
WithdrawalRequestStatus,
|
||||
)
|
||||
from app.database.models import ReferralEarning, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.referral import (
|
||||
@@ -46,15 +38,12 @@ async def get_referral_info(
|
||||
total_result = await db.execute(total_query)
|
||||
total_referrals = total_result.scalar() or 0
|
||||
|
||||
# Get active referrals (with active subscription right now)
|
||||
# Get active referrals (with subscription)
|
||||
active_query = (
|
||||
select(func.count(func.distinct(User.id)))
|
||||
.join(Subscription, User.id == Subscription.user_id)
|
||||
.where(
|
||||
User.referred_by_id == user.id,
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.end_date > func.now(),
|
||||
)
|
||||
select(func.count())
|
||||
.select_from(User)
|
||||
.where(User.referred_by_id == user.id)
|
||||
.where(User.has_had_paid_subscription == True)
|
||||
)
|
||||
active_result = await db.execute(active_query)
|
||||
active_referrals = active_result.scalar() or 0
|
||||
@@ -71,42 +60,18 @@ async def get_referral_info(
|
||||
if commission_percent is None:
|
||||
commission_percent = settings.REFERRAL_COMMISSION_PERCENT
|
||||
|
||||
# Get withdrawn amount (approved + completed withdrawal requests)
|
||||
withdrawn_query = select(func.coalesce(func.sum(WithdrawalRequest.amount_kopeks), 0)).where(
|
||||
WithdrawalRequest.user_id == user.id,
|
||||
WithdrawalRequest.status.in_([WithdrawalRequestStatus.APPROVED.value, WithdrawalRequestStatus.COMPLETED.value]),
|
||||
)
|
||||
withdrawn_result = await db.execute(withdrawn_query)
|
||||
withdrawn = withdrawn_result.scalar() or 0
|
||||
|
||||
# Get pending withdrawal amount
|
||||
pending_query = select(func.coalesce(func.sum(WithdrawalRequest.amount_kopeks), 0)).where(
|
||||
WithdrawalRequest.user_id == user.id,
|
||||
WithdrawalRequest.status == WithdrawalRequestStatus.PENDING.value,
|
||||
)
|
||||
pending_result = await db.execute(pending_query)
|
||||
pending = pending_result.scalar() or 0
|
||||
|
||||
# Доступный баланс: мин(кошелёк, заработано - выведено - в ожидании)
|
||||
referral_entitlement = max(0, total_earnings - withdrawn - pending)
|
||||
available_balance = min(user.balance_kopeks, referral_entitlement)
|
||||
|
||||
# Build referral links
|
||||
referral_link = (settings.get_cabinet_referral_link(user.referral_code) or '') if user.referral_code else ''
|
||||
bot_referral_link = settings.get_bot_referral_link(user.referral_code) if user.referral_code else ''
|
||||
# Build referral link
|
||||
bot_username = settings.get_bot_username() or 'bot'
|
||||
referral_link = f'https://t.me/{bot_username}?start={user.referral_code}'
|
||||
|
||||
return ReferralInfoResponse(
|
||||
referral_code=user.referral_code or '',
|
||||
referral_link=referral_link,
|
||||
bot_referral_link=bot_referral_link,
|
||||
total_referrals=total_referrals,
|
||||
active_referrals=active_referrals,
|
||||
total_earnings_kopeks=total_earnings,
|
||||
total_earnings_rubles=total_earnings / 100,
|
||||
commission_percent=commission_percent,
|
||||
available_balance_kopeks=available_balance,
|
||||
available_balance_rubles=available_balance / 100,
|
||||
withdrawn_kopeks=withdrawn,
|
||||
)
|
||||
|
||||
|
||||
@@ -119,11 +84,7 @@ async def get_referral_list(
|
||||
):
|
||||
"""Get list of invited users."""
|
||||
# Base query with eager loading of subscription relationship
|
||||
query = (
|
||||
select(User)
|
||||
.options(selectinload(User.subscriptions).selectinload(Subscription.tariff))
|
||||
.where(User.referred_by_id == user.id)
|
||||
)
|
||||
query = select(User).options(selectinload(User.subscription)).where(User.referred_by_id == user.id)
|
||||
|
||||
# Get total count
|
||||
count_query = select(func.count()).select_from(User).where(User.referred_by_id == user.id)
|
||||
@@ -143,7 +104,7 @@ async def get_referral_list(
|
||||
username=r.username,
|
||||
first_name=r.first_name,
|
||||
created_at=r.created_at,
|
||||
has_subscription=bool(getattr(r, 'subscriptions', None)),
|
||||
has_subscription=r.subscription is not None,
|
||||
has_paid=r.has_had_paid_subscription,
|
||||
)
|
||||
for r in referrals
|
||||
@@ -189,26 +150,12 @@ async def get_referral_earnings(
|
||||
result = await db.execute(query)
|
||||
earnings = result.scalars().all()
|
||||
|
||||
# Batch-fetch referral users to avoid N+1
|
||||
referral_ids = list({e.referral_id for e in earnings if e.referral_id})
|
||||
if referral_ids:
|
||||
referral_users_result = await db.execute(select(User).where(User.id.in_(referral_ids)))
|
||||
referral_users_map = {u.id: u for u in referral_users_result.scalars().all()}
|
||||
else:
|
||||
referral_users_map = {}
|
||||
|
||||
# Batch-fetch campaigns to avoid N+1
|
||||
campaign_ids = list({e.campaign_id for e in earnings if e.campaign_id})
|
||||
if campaign_ids:
|
||||
campaigns_result = await db.execute(select(AdvertisingCampaign).where(AdvertisingCampaign.id.in_(campaign_ids)))
|
||||
campaigns_map = {c.id: c for c in campaigns_result.scalars().all()}
|
||||
else:
|
||||
campaigns_map = {}
|
||||
|
||||
items = []
|
||||
for e in earnings:
|
||||
referral_user = referral_users_map.get(e.referral_id) if e.referral_id else None
|
||||
campaign = campaigns_map.get(e.campaign_id) if e.campaign_id else None
|
||||
# Get referral user info
|
||||
referral_query = select(User).where(User.id == e.referral_id)
|
||||
referral_result = await db.execute(referral_query)
|
||||
referral_user = referral_result.scalar_one_or_none()
|
||||
|
||||
items.append(
|
||||
ReferralEarningResponse(
|
||||
@@ -218,7 +165,6 @@ async def get_referral_earnings(
|
||||
reason=e.reason or 'Referral commission',
|
||||
referral_username=referral_user.username if referral_user else None,
|
||||
referral_first_name=referral_user.first_name if referral_user else None,
|
||||
campaign_name=campaign.name if campaign else None,
|
||||
created_at=e.created_at,
|
||||
)
|
||||
)
|
||||
@@ -248,6 +194,4 @@ async def get_referral_terms():
|
||||
first_topup_bonus_rubles=settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS / 100,
|
||||
inviter_bonus_kopeks=settings.REFERRAL_INVITER_BONUS_KOPEKS,
|
||||
inviter_bonus_rubles=settings.REFERRAL_INVITER_BONUS_KOPEKS / 100,
|
||||
max_commission_payments=settings.REFERRAL_MAX_COMMISSION_PAYMENTS,
|
||||
partner_section_visible=settings.REFERRAL_PARTNER_SECTION_VISIBLE,
|
||||
)
|
||||
|
||||
+4757
-38
File diff suppressed because it is too large
Load Diff
@@ -1,32 +0,0 @@
|
||||
"""Subscription sub-modules for cabinet API.
|
||||
|
||||
Each module contains a subset of endpoints from the original monolithic subscription.py.
|
||||
The main subscription.py includes all sub-routers for backward compatibility.
|
||||
"""
|
||||
|
||||
from .autopay import router as autopay_router
|
||||
from .daily import router as daily_router
|
||||
from .devices import router as devices_router
|
||||
from .multi_tariff import router as multi_tariff_router
|
||||
from .purchase import router as purchase_router
|
||||
from .renewal import router as renewal_router
|
||||
from .revoke import router as revoke_router
|
||||
from .servers import router as servers_router
|
||||
from .status import router as status_router
|
||||
from .tariff_switch import router as tariff_switch_router
|
||||
from .traffic import router as traffic_router
|
||||
|
||||
|
||||
__all__ = [
|
||||
'autopay_router',
|
||||
'daily_router',
|
||||
'devices_router',
|
||||
'multi_tariff_router',
|
||||
'purchase_router',
|
||||
'renewal_router',
|
||||
'revoke_router',
|
||||
'servers_router',
|
||||
'status_router',
|
||||
'tariff_switch_router',
|
||||
'traffic_router',
|
||||
]
|
||||
@@ -1,79 +0,0 @@
|
||||
"""Autopay settings endpoint.
|
||||
|
||||
PATCH /subscription/autopay
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
|
||||
from ...dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ...schemas.subscription import AutopayUpdateRequest
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.patch('/autopay')
|
||||
async def update_autopay(
|
||||
request: AutopayUpdateRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'),
|
||||
):
|
||||
"""Update autopay settings."""
|
||||
from .helpers import resolve_subscription
|
||||
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='No subscription found',
|
||||
)
|
||||
|
||||
if request.enabled:
|
||||
# Classic subscriptions cannot use autopay when tariff mode is enabled
|
||||
from app.config import settings
|
||||
|
||||
if settings.is_tariffs_mode() and not subscription.tariff_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Autopay is not available for classic subscriptions. Please purchase a tariff.',
|
||||
)
|
||||
|
||||
# Триальные подписки — пробник, автопродление не имеет смысла
|
||||
# NULL-safe: is_trial can be None in legacy rows — treat as trial
|
||||
if subscription.is_trial is not False:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Autopay is not available for trial subscriptions',
|
||||
)
|
||||
|
||||
# Суточные подписки имеют свой механизм продления (DailySubscriptionService),
|
||||
# глобальный autopay для них запрещён
|
||||
await db.refresh(subscription, ['tariff'])
|
||||
if subscription.tariff and getattr(subscription.tariff, 'is_daily', False):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Autopay is not available for daily subscriptions',
|
||||
)
|
||||
|
||||
subscription.autopay_enabled = request.enabled
|
||||
|
||||
if request.days_before is not None:
|
||||
subscription.autopay_days_before = request.days_before
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
'message': 'Autopay settings updated',
|
||||
'autopay_enabled': subscription.autopay_enabled,
|
||||
'autopay_days_before': subscription.autopay_days_before,
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
"""Daily subscription management endpoints.
|
||||
|
||||
POST /subscription/pause
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query as QueryParam, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.database.models import User
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
from ...dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from .helpers import resolve_subscription
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post('/pause')
|
||||
async def toggle_subscription_pause(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'),
|
||||
) -> dict[str, Any]:
|
||||
"""Toggle pause/resume for daily subscription."""
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='No subscription found',
|
||||
)
|
||||
|
||||
tariff_id = getattr(subscription, 'tariff_id', None)
|
||||
if not tariff_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Subscription has no tariff',
|
||||
)
|
||||
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
if not tariff or not getattr(tariff, 'is_daily', False):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Pause is only available for daily tariffs',
|
||||
)
|
||||
|
||||
# Determine current state
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
is_currently_paused = getattr(subscription, 'is_daily_paused', False)
|
||||
was_disabled = subscription.status in (
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
)
|
||||
|
||||
# System-DISABLED subs (insufficient balance) should always be treated as needing resume,
|
||||
# even if is_daily_paused is False (it's set by the system, not the user)
|
||||
if was_disabled and not is_currently_paused:
|
||||
new_paused_state = False # Force resume path
|
||||
else:
|
||||
new_paused_state = not is_currently_paused
|
||||
|
||||
raw_daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
|
||||
# Lock user BEFORE discount computation to prevent TOCTOU on promo group
|
||||
# IMPORTANT: must happen BEFORE modifying subscription — lock_user_for_pricing
|
||||
# reloads subscriptions via selectinload which resets in-memory changes
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
# Re-fetch subscription after lock (selectinload may have replaced the ORM object)
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
if not subscription:
|
||||
raise HTTPException(status_code=404, detail='Subscription not found after lock')
|
||||
|
||||
subscription.is_daily_paused = new_paused_state
|
||||
|
||||
# Apply group discount to daily price (consistent with DailySubscriptionService and miniapp resume)
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
promo_group = PricingEngine.resolve_promo_group(user)
|
||||
daily_group_pct = promo_group.get_discount_percent('period', 1) if promo_group else 0
|
||||
daily_price = (
|
||||
PricingEngine.apply_discount(raw_daily_price, daily_group_pct) if daily_group_pct > 0 else raw_daily_price
|
||||
)
|
||||
|
||||
# If resuming, check balance and charge
|
||||
if not new_paused_state:
|
||||
if daily_price > 0 and user.balance_kopeks < daily_price:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail={
|
||||
'code': 'insufficient_balance',
|
||||
'message': 'Insufficient balance to resume daily subscription',
|
||||
'required': daily_price,
|
||||
'balance': user.balance_kopeks,
|
||||
},
|
||||
)
|
||||
|
||||
# Charge daily fee FIRST, then restore ACTIVE status
|
||||
if was_disabled:
|
||||
if daily_price > 0:
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
|
||||
deducted = await subtract_user_balance(
|
||||
db,
|
||||
user,
|
||||
daily_price,
|
||||
f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
|
||||
mark_as_paid_subscription=True,
|
||||
)
|
||||
if not deducted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail={
|
||||
'code': 'insufficient_balance',
|
||||
'message': 'Balance deduction failed',
|
||||
'required': daily_price,
|
||||
'balance': user.balance_kopeks,
|
||||
},
|
||||
)
|
||||
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.models import TransactionType
|
||||
|
||||
try:
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=daily_price,
|
||||
description=f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning('Failed to create resume transaction', error=exc)
|
||||
|
||||
# Balance deducted successfully — now activate
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.last_daily_charge_at = datetime.now(UTC)
|
||||
subscription.end_date = datetime.now(UTC) + timedelta(days=1)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
await db.refresh(user)
|
||||
|
||||
# Sync with RemnaWave only when resuming from DISABLED state
|
||||
if not new_paused_state and was_disabled:
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=False,
|
||||
reset_reason=None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Error syncing RemnaWave user on resume', error=e)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
action='create',
|
||||
)
|
||||
|
||||
if new_paused_state:
|
||||
message = 'Daily subscription paused'
|
||||
else:
|
||||
message = 'Daily subscription resumed'
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': message,
|
||||
'is_paused': new_paused_state,
|
||||
'balance_kopeks': user.balance_kopeks,
|
||||
'balance_label': settings.format_price(user.balance_kopeks),
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,234 +0,0 @@
|
||||
"""Shared helper functions for subscription modules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import structlog
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import Subscription, User
|
||||
|
||||
from ...schemas.subscription import (
|
||||
ServerInfo,
|
||||
SubscriptionResponse,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def resolve_subscription(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
subscription_id: int | None,
|
||||
) -> Subscription | None:
|
||||
"""Resolve target subscription: by ID in multi-tariff mode, or legacy fallback.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
user: Current user.
|
||||
subscription_id: Optional subscription ID (from query param).
|
||||
|
||||
Returns:
|
||||
Target Subscription or None if not found.
|
||||
|
||||
Raises:
|
||||
HTTPException: If subscription_id provided but not found for this user.
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.database.crud.subscription import get_subscription_by_id_for_user
|
||||
|
||||
if subscription_id and settings.is_multi_tariff_enabled():
|
||||
subscription = await get_subscription_by_id_for_user(db, subscription_id, user.id)
|
||||
if not subscription:
|
||||
raise HTTPException(status_code=404, detail='Subscription not found')
|
||||
return subscription
|
||||
|
||||
if settings.is_multi_tariff_enabled() and not subscription_id:
|
||||
from app.database.crud.subscription import get_active_subscriptions_by_user_id
|
||||
|
||||
active_subs = await get_active_subscriptions_by_user_id(db, user.id)
|
||||
if active_subs:
|
||||
non_daily = [s for s in active_subs if not getattr(s, 'is_daily_tariff', False)]
|
||||
pool = non_daily or active_subs
|
||||
return max(pool, key=lambda s: s.days_left)
|
||||
return None
|
||||
|
||||
await db.refresh(user, ['subscriptions'])
|
||||
return user.subscription
|
||||
|
||||
|
||||
def _get_addon_discount_percent(
|
||||
user: User | None,
|
||||
category: str,
|
||||
period_days_hint: int | None = None,
|
||||
) -> int:
|
||||
"""Get addon discount percent for user — delegates to PricingEngine."""
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
return PricingEngine.get_addon_discount_percent(user, category, period_days_hint)
|
||||
|
||||
|
||||
def _apply_addon_discount(
|
||||
user: User,
|
||||
category: str,
|
||||
amount: int,
|
||||
period_days: int | None = None,
|
||||
) -> dict[str, int]:
|
||||
"""Apply addon discount to amount.
|
||||
|
||||
Returns dict with keys: discounted, discount, percent
|
||||
"""
|
||||
from app.utils.pricing_utils import apply_percentage_discount
|
||||
|
||||
percent = _get_addon_discount_percent(user, category, period_days)
|
||||
if percent <= 0 or amount <= 0:
|
||||
return {'discounted': amount, 'discount': 0, 'percent': 0}
|
||||
|
||||
discounted_amount, discount_value = apply_percentage_discount(amount, percent)
|
||||
return {
|
||||
'discounted': discounted_amount,
|
||||
'discount': discount_value,
|
||||
'percent': percent,
|
||||
}
|
||||
|
||||
|
||||
def _subscription_to_response(
|
||||
subscription: Subscription,
|
||||
servers: list[ServerInfo] | None = None,
|
||||
tariff_name: str | None = None,
|
||||
traffic_purchases: list[dict[str, Any]] | None = None,
|
||||
user: User | None = None,
|
||||
) -> SubscriptionResponse:
|
||||
"""Convert Subscription model to response."""
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Use actual_status property for correct status (same as bot uses)
|
||||
actual_status = subscription.actual_status
|
||||
is_expired = actual_status == 'expired'
|
||||
is_active = actual_status in ('active', 'trial')
|
||||
is_limited = actual_status == 'limited'
|
||||
|
||||
# Calculate time remaining
|
||||
days_left = 0
|
||||
hours_left = 0
|
||||
minutes_left = 0
|
||||
time_left_display = ''
|
||||
|
||||
if subscription.end_date and not is_expired:
|
||||
time_delta = subscription.end_date - now
|
||||
total_seconds = max(0, int(time_delta.total_seconds()))
|
||||
|
||||
days_left = total_seconds // 86400 # 86400 seconds in a day
|
||||
remaining_seconds = total_seconds % 86400
|
||||
hours_left = remaining_seconds // 3600
|
||||
minutes_left = (remaining_seconds % 3600) // 60
|
||||
|
||||
# Create human-readable display
|
||||
if days_left > 0:
|
||||
time_left_display = f'{days_left}d {hours_left}h'
|
||||
elif hours_left > 0:
|
||||
time_left_display = f'{hours_left}h {minutes_left}m'
|
||||
elif minutes_left > 0:
|
||||
time_left_display = f'{minutes_left}m'
|
||||
else:
|
||||
time_left_display = '0m'
|
||||
else:
|
||||
time_left_display = '0m'
|
||||
|
||||
traffic_limit_gb = subscription.traffic_limit_gb or 0
|
||||
traffic_used_gb = subscription.traffic_used_gb or 0.0
|
||||
|
||||
if traffic_limit_gb > 0:
|
||||
traffic_used_percent = min(100, (traffic_used_gb / traffic_limit_gb) * 100)
|
||||
else:
|
||||
traffic_used_percent = 0
|
||||
|
||||
# Check if this is a daily tariff
|
||||
is_daily_paused = getattr(subscription, 'is_daily_paused', False) or False
|
||||
tariff_id = getattr(subscription, 'tariff_id', None)
|
||||
|
||||
# Use subscription's is_daily_tariff property if available
|
||||
is_daily = False
|
||||
daily_price_kopeks = None
|
||||
|
||||
if hasattr(subscription, 'is_daily_tariff'):
|
||||
is_daily = subscription.is_daily_tariff
|
||||
elif tariff_id and hasattr(subscription, 'tariff') and subscription.tariff:
|
||||
is_daily = getattr(subscription.tariff, 'is_daily', False)
|
||||
|
||||
# Get daily_price_kopeks, tariff_name, traffic_reset_mode from tariff
|
||||
traffic_reset_mode = None
|
||||
if tariff_id and hasattr(subscription, 'tariff') and subscription.tariff:
|
||||
daily_price_kopeks = getattr(subscription.tariff, 'daily_price_kopeks', None)
|
||||
# Применяем скидку промогруппы + promo-offer для отображения
|
||||
if daily_price_kopeks and daily_price_kopeks > 0 and user:
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||
|
||||
_promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
|
||||
_group_pct = _promo_group.get_discount_percent('period', 1) if _promo_group else 0
|
||||
_offer_pct = get_user_active_promo_discount_percent(user)
|
||||
if _group_pct > 0 or _offer_pct > 0:
|
||||
daily_price_kopeks, _, _ = PricingEngine.apply_stacked_discounts(
|
||||
daily_price_kopeks, _group_pct, _offer_pct
|
||||
)
|
||||
if not tariff_name: # Only set if not passed as parameter
|
||||
tariff_name = getattr(subscription.tariff, 'name', None)
|
||||
traffic_reset_mode = (
|
||||
getattr(subscription.tariff, 'traffic_reset_mode', None) or settings.DEFAULT_TRAFFIC_RESET_STRATEGY
|
||||
)
|
||||
|
||||
# Calculate next daily charge time (24 hours after last charge)
|
||||
next_daily_charge_at = None
|
||||
if is_daily and not is_daily_paused:
|
||||
last_charge = getattr(subscription, 'last_daily_charge_at', None)
|
||||
if last_charge:
|
||||
next_charge = last_charge + timedelta(days=1)
|
||||
# Если время списания уже прошло — не показываем (DailySubscriptionService обработает)
|
||||
if next_charge > datetime.now(UTC):
|
||||
next_daily_charge_at = next_charge
|
||||
|
||||
# Проверяем настройку скрытия ссылки (скрывается только текст, кнопки работают)
|
||||
hide_link = settings.should_hide_subscription_link()
|
||||
|
||||
return SubscriptionResponse(
|
||||
id=subscription.id,
|
||||
status=actual_status, # Use actual_status instead of raw status
|
||||
is_trial=subscription.is_trial or actual_status == 'trial',
|
||||
start_date=subscription.start_date,
|
||||
end_date=subscription.end_date,
|
||||
days_left=days_left,
|
||||
hours_left=hours_left,
|
||||
minutes_left=minutes_left,
|
||||
time_left_display=time_left_display,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
traffic_used_gb=round(traffic_used_gb, 2),
|
||||
traffic_used_percent=round(traffic_used_percent, 1),
|
||||
device_limit=subscription.device_limit or 0,
|
||||
connected_squads=subscription.connected_squads or [],
|
||||
servers=servers or [],
|
||||
autopay_enabled=subscription.autopay_enabled or False,
|
||||
autopay_days_before=subscription.autopay_days_before or 3,
|
||||
subscription_url=subscription.subscription_url,
|
||||
hide_subscription_link=hide_link,
|
||||
is_active=is_active,
|
||||
is_expired=is_expired,
|
||||
is_limited=is_limited,
|
||||
traffic_purchases=traffic_purchases or [],
|
||||
is_daily=is_daily,
|
||||
is_daily_paused=is_daily_paused,
|
||||
daily_price_kopeks=daily_price_kopeks,
|
||||
next_daily_charge_at=next_daily_charge_at,
|
||||
tariff_id=tariff_id,
|
||||
tariff_name=tariff_name,
|
||||
traffic_reset_mode=traffic_reset_mode,
|
||||
)
|
||||
@@ -1,156 +0,0 @@
|
||||
"""Multi-tariff subscription endpoints for cabinet API.
|
||||
|
||||
GET /subscriptions — list all user subscriptions (multi-tariff)
|
||||
GET /subscriptions/{id} — get specific subscription details
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.subscription import (
|
||||
decrement_subscription_server_counts,
|
||||
get_all_subscriptions_by_user_id,
|
||||
get_subscription_by_id_for_user,
|
||||
)
|
||||
from app.database.models import SubscriptionStatus, User
|
||||
|
||||
from ...dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/subscriptions', tags=['Cabinet Multi-Tariff'], redirect_slashes=False)
|
||||
|
||||
|
||||
class SubscriptionListItem(BaseModel):
|
||||
id: int
|
||||
status: str
|
||||
tariff_id: int | None = None
|
||||
tariff_name: str | None = None
|
||||
traffic_limit_gb: int = 0
|
||||
traffic_used_gb: float = 0.0
|
||||
device_limit: int = 1
|
||||
end_date: str | None = None
|
||||
subscription_url: str | None = None
|
||||
subscription_crypto_link: str | None = None
|
||||
is_trial: bool = False
|
||||
is_daily: bool = False
|
||||
is_daily_paused: bool = False
|
||||
autopay_enabled: bool = False
|
||||
connected_squads: list[str] | None = None
|
||||
|
||||
|
||||
class SubscriptionsListResponse(BaseModel):
|
||||
subscriptions: list[SubscriptionListItem]
|
||||
multi_tariff_enabled: bool
|
||||
|
||||
|
||||
def _subscription_to_list_item(sub) -> SubscriptionListItem:
|
||||
tariff_name = None
|
||||
if sub.tariff:
|
||||
tariff_name = sub.tariff.name
|
||||
|
||||
return SubscriptionListItem(
|
||||
id=sub.id,
|
||||
status=sub.actual_status,
|
||||
tariff_id=sub.tariff_id,
|
||||
tariff_name=tariff_name,
|
||||
traffic_limit_gb=sub.traffic_limit_gb or 0,
|
||||
traffic_used_gb=sub.traffic_used_gb or 0.0,
|
||||
device_limit=sub.device_limit or 1,
|
||||
end_date=sub.end_date.isoformat() if sub.end_date else None,
|
||||
subscription_url=sub.subscription_url,
|
||||
subscription_crypto_link=sub.subscription_crypto_link,
|
||||
is_trial=sub.is_trial or False,
|
||||
is_daily=bool(sub.tariff and getattr(sub.tariff, 'is_daily', False)),
|
||||
is_daily_paused=bool(getattr(sub, 'is_daily_paused', False)),
|
||||
autopay_enabled=sub.autopay_enabled or False,
|
||||
connected_squads=sub.connected_squads,
|
||||
)
|
||||
|
||||
|
||||
@router.get('', response_model=SubscriptionsListResponse)
|
||||
async def list_subscriptions(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SubscriptionsListResponse:
|
||||
"""List all user subscriptions. Returns all subscriptions regardless of multi-tariff mode."""
|
||||
subscriptions = await get_all_subscriptions_by_user_id(db, user.id)
|
||||
items = [_subscription_to_list_item(sub) for sub in subscriptions]
|
||||
return SubscriptionsListResponse(
|
||||
subscriptions=items,
|
||||
multi_tariff_enabled=settings.is_multi_tariff_enabled(),
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{subscription_id}', response_model=SubscriptionListItem)
|
||||
async def get_subscription_detail(
|
||||
subscription_id: int,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SubscriptionListItem:
|
||||
"""Get specific subscription details with ownership check."""
|
||||
subscription = await get_subscription_by_id_for_user(db, subscription_id, user.id)
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Subscription not found',
|
||||
)
|
||||
return _subscription_to_list_item(subscription)
|
||||
|
||||
|
||||
@router.delete('/{subscription_id}')
|
||||
async def delete_subscription(
|
||||
subscription_id: int,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict:
|
||||
"""Delete an expired/disabled subscription. Active subscriptions cannot be deleted."""
|
||||
subscription = await get_subscription_by_id_for_user(db, subscription_id, user.id)
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Subscription not found',
|
||||
)
|
||||
|
||||
# Only expired/disabled subscriptions can be deleted
|
||||
deletable_statuses = {
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
}
|
||||
if getattr(subscription, 'actual_status', subscription.status) not in deletable_statuses:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Only expired or disabled subscriptions can be deleted',
|
||||
)
|
||||
|
||||
# Delete from RemnaWave panel (stops webhooks / phantom notifications)
|
||||
if subscription.remnawave_uuid:
|
||||
try:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
service = SubscriptionService()
|
||||
await service.delete_remnawave_user(subscription.remnawave_uuid)
|
||||
except Exception as e:
|
||||
logger.warning('Failed to delete RemnaWave user on subscription delete', error=e)
|
||||
|
||||
# Decrement server counts
|
||||
await decrement_subscription_server_counts(db, subscription)
|
||||
|
||||
# Delete the subscription
|
||||
await db.delete(subscription)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Subscription deleted by user',
|
||||
subscription_id=subscription_id,
|
||||
user_id=user.id,
|
||||
tariff_id=subscription.tariff_id,
|
||||
)
|
||||
|
||||
return {'message': 'Subscription deleted'}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,281 +0,0 @@
|
||||
"""Subscription renewal endpoints.
|
||||
|
||||
GET /subscription/renewal-options
|
||||
POST /subscription/renew
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.database.models import PaymentMethod, SubscriptionStatus, User
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
from app.services.subscription_renewal_service import (
|
||||
SubscriptionRenewalChargeError,
|
||||
SubscriptionRenewalService,
|
||||
)
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
|
||||
from ...dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ...schemas.subscription import (
|
||||
RenewalOptionResponse,
|
||||
RenewalRequest,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('/renewal-options', response_model=list[RenewalOptionResponse])
|
||||
async def get_renewal_options(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'),
|
||||
):
|
||||
"""Get available subscription renewal options with prices."""
|
||||
from .helpers import resolve_subscription
|
||||
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
if not subscription:
|
||||
return []
|
||||
|
||||
# Classic subscriptions cannot be renewed when tariff mode is enabled
|
||||
if settings.is_tariffs_mode() and not subscription.tariff_id:
|
||||
return []
|
||||
|
||||
_non_renewable = {SubscriptionStatus.DISABLED.value, SubscriptionStatus.PENDING.value}
|
||||
_actual_status = getattr(subscription, 'actual_status', subscription.status)
|
||||
if _actual_status in _non_renewable:
|
||||
return []
|
||||
|
||||
# Determine available periods
|
||||
# Скрытый/неактивный тариф (например, триальный после промокода) —
|
||||
# не показываем его периоды, используем стандартные
|
||||
if (
|
||||
subscription.tariff_id
|
||||
and subscription.tariff
|
||||
and subscription.tariff.is_active
|
||||
and subscription.tariff.period_prices
|
||||
):
|
||||
periods = sorted(int(k) for k in subscription.tariff.period_prices.keys())
|
||||
else:
|
||||
periods = settings.get_available_renewal_periods()
|
||||
|
||||
options = []
|
||||
|
||||
for period in periods:
|
||||
pricing = await pricing_engine.calculate_renewal_price(db, subscription, period, user=user)
|
||||
|
||||
if pricing.final_total <= 0 and pricing.original_total <= 0:
|
||||
continue
|
||||
|
||||
original_price = pricing.original_total
|
||||
combined_discount = 0
|
||||
if original_price > 0 and original_price != pricing.final_total:
|
||||
combined_discount = int((original_price - pricing.final_total) * 100 / original_price)
|
||||
|
||||
options.append(
|
||||
RenewalOptionResponse(
|
||||
period_days=period,
|
||||
price_kopeks=pricing.final_total,
|
||||
price_rubles=pricing.final_total / 100,
|
||||
discount_percent=combined_discount,
|
||||
original_price_kopeks=original_price if combined_discount > 0 else None,
|
||||
)
|
||||
)
|
||||
|
||||
return options
|
||||
|
||||
|
||||
@router.post('/renew')
|
||||
async def renew_subscription(
|
||||
request: RenewalRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'),
|
||||
):
|
||||
"""Renew subscription (pay from balance)."""
|
||||
if getattr(user, 'restriction_subscription', False):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Subscription renewal is restricted for this account',
|
||||
)
|
||||
|
||||
# Support subscription_id from both query param and body (backward compat)
|
||||
from .helpers import resolve_subscription
|
||||
|
||||
_sub_id = subscription_id or request.subscription_id
|
||||
subscription = await resolve_subscription(db, user, _sub_id)
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='No subscription found',
|
||||
)
|
||||
|
||||
# Classic subscriptions cannot be renewed when tariff mode is enabled
|
||||
if settings.is_tariffs_mode() and not subscription.tariff_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Classic subscriptions cannot be renewed. Please purchase a tariff.',
|
||||
)
|
||||
|
||||
_non_renewable = {SubscriptionStatus.DISABLED.value, SubscriptionStatus.PENDING.value}
|
||||
_actual_status = getattr(subscription, 'actual_status', subscription.status)
|
||||
if _actual_status in _non_renewable:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Cannot renew subscription with status: {_actual_status}',
|
||||
)
|
||||
|
||||
if (
|
||||
subscription.tariff_id
|
||||
and subscription.tariff
|
||||
and subscription.tariff.is_active
|
||||
and subscription.tariff.period_prices
|
||||
):
|
||||
available_periods = [int(p) for p in subscription.tariff.period_prices.keys()]
|
||||
else:
|
||||
available_periods = settings.get_available_renewal_periods()
|
||||
|
||||
if request.period_days not in available_periods:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Selected renewal period is not available',
|
||||
)
|
||||
|
||||
# Lock user row to prevent TOCTOU on promo-offer state
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
# Unified pricing via PricingEngine
|
||||
pricing = await pricing_engine.calculate_renewal_price(
|
||||
db,
|
||||
subscription,
|
||||
request.period_days,
|
||||
user=user,
|
||||
)
|
||||
price_kopeks = pricing.final_total
|
||||
promo_offer_discount_value = pricing.promo_offer_discount
|
||||
promo_offer_discount_percent = pricing.breakdown.get('offer_discount_pct', 0)
|
||||
|
||||
if price_kopeks <= 0 and pricing.original_total <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid renewal period',
|
||||
)
|
||||
|
||||
original_price_kopeks = pricing.original_total
|
||||
discount_percent = 0
|
||||
if original_price_kopeks > 0 and original_price_kopeks != price_kopeks:
|
||||
discount_percent = int((original_price_kopeks - price_kopeks) * 100 / original_price_kopeks)
|
||||
|
||||
tariff = subscription.tariff if subscription.tariff_id else None
|
||||
|
||||
# Check balance (skip for 100% discount)
|
||||
if price_kopeks > 0 and user.balance_kopeks < price_kopeks:
|
||||
missing = price_kopeks - user.balance_kopeks
|
||||
|
||||
# Get tariff info for cart
|
||||
tariff_id = subscription.tariff_id
|
||||
tariff_name = None
|
||||
tariff_traffic_limit_gb = None
|
||||
tariff_allowed_squads = None
|
||||
|
||||
if tariff_id:
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
if tariff:
|
||||
tariff_name = tariff.name
|
||||
tariff_traffic_limit_gb = tariff.traffic_limit_gb
|
||||
tariff_allowed_squads = tariff.allowed_squads or []
|
||||
|
||||
# Save cart for auto-purchase after balance top-up
|
||||
cart_data: dict[str, Any] = {
|
||||
'cart_mode': 'extend',
|
||||
'subscription_id': subscription.id,
|
||||
'tariff_id': tariff_id,
|
||||
'period_days': request.period_days,
|
||||
'total_price': price_kopeks,
|
||||
'user_id': user.id,
|
||||
'saved_cart': True,
|
||||
'missing_amount': missing,
|
||||
'return_to_cart': True,
|
||||
'description': f'Продление подписки на {request.period_days} дней'
|
||||
+ (f' ({tariff_name})' if tariff_name else ''),
|
||||
'discount_percent': discount_percent,
|
||||
'consume_promo_offer': promo_offer_discount_value > 0,
|
||||
'source': 'cabinet',
|
||||
}
|
||||
|
||||
# Add subscription parameters for auto-purchase
|
||||
if tariff_id:
|
||||
cart_data['traffic_limit_gb'] = tariff_traffic_limit_gb
|
||||
# Сохраняем актуальный device_limit подписки (включая докупленные устройства)
|
||||
cart_data['device_limit'] = subscription.device_limit
|
||||
cart_data['allowed_squads'] = tariff_allowed_squads
|
||||
else:
|
||||
# Classic mode: сохраняем текущие параметры подписки для корректной автопокупки
|
||||
cart_data['device_limit'] = subscription.device_limit
|
||||
cart_data['traffic_limit_gb'] = subscription.traffic_limit_gb
|
||||
|
||||
try:
|
||||
await user_cart_service.save_user_cart(user.id, cart_data)
|
||||
logger.info('Cart saved for auto-renewal (cabinet) user', user_id=user.id)
|
||||
except Exception as e:
|
||||
logger.error('Error saving cart for auto-renewal (cabinet)', error=e)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail={
|
||||
'code': 'insufficient_funds',
|
||||
'message': f'Недостаточно средств. Не хватает {settings.format_price(missing)}',
|
||||
'missing_amount': missing,
|
||||
'cart_saved': True,
|
||||
'cart_mode': 'extend',
|
||||
},
|
||||
)
|
||||
|
||||
# Centralized renewal: balance deduction, extension, RemnaWave sync, admin notification,
|
||||
# server price recording, and compensating refund on failure.
|
||||
renewal_description = f'Продление подписки на {request.period_days} дней' + (f' ({tariff.name})' if tariff else '')
|
||||
renewal_service = SubscriptionRenewalService()
|
||||
|
||||
try:
|
||||
result = await renewal_service.finalize(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
pricing,
|
||||
description=renewal_description,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
except SubscriptionRenewalChargeError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail={
|
||||
'code': 'insufficient_funds',
|
||||
'message': 'Недостаточно средств (concurrent check)',
|
||||
},
|
||||
)
|
||||
|
||||
response: dict[str, Any] = {
|
||||
'message': 'Subscription renewed successfully',
|
||||
'new_end_date': result.subscription.end_date.isoformat(),
|
||||
'amount_paid_kopeks': price_kopeks,
|
||||
}
|
||||
|
||||
# Add discount info to response
|
||||
if promo_offer_discount_value > 0:
|
||||
response['promo_discount_percent'] = promo_offer_discount_percent
|
||||
response['promo_discount_amount_kopeks'] = promo_offer_discount_value
|
||||
response['original_price_kopeks'] = original_price_kopeks
|
||||
|
||||
return response
|
||||
@@ -1,92 +0,0 @@
|
||||
"""Cabinet API endpoint for subscription reissue.
|
||||
|
||||
POST /subscription/revoke
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
from ...dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from .helpers import resolve_subscription
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post('/revoke')
|
||||
async def revoke_subscription(
|
||||
subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'),
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict:
|
||||
"""Revoke and reissue subscription (generate new connection link)."""
|
||||
if not settings.is_subscription_revoke_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Subscription reissue is not available',
|
||||
)
|
||||
|
||||
# Reload user from current session
|
||||
from app.database.crud.user import get_user_by_id
|
||||
|
||||
fresh_user = await get_user_by_id(db, user.id)
|
||||
if not fresh_user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found')
|
||||
|
||||
subscription = await resolve_subscription(db, fresh_user, subscription_id)
|
||||
if not subscription:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Subscription not found')
|
||||
|
||||
if not subscription.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Only active subscriptions can be reissued',
|
||||
)
|
||||
|
||||
# Check cooldown
|
||||
if subscription.last_revoke_at:
|
||||
elapsed = (datetime.now(UTC) - subscription.last_revoke_at).total_seconds()
|
||||
cooldown = settings.SUBSCRIPTION_REVOKE_COOLDOWN_SECONDS
|
||||
if elapsed < cooldown:
|
||||
remaining = int(cooldown - elapsed)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f'Cooldown active. Try again in {remaining} seconds.',
|
||||
headers={'Retry-After': str(remaining)},
|
||||
)
|
||||
|
||||
# Execute revoke
|
||||
sub_service = SubscriptionService()
|
||||
new_url = await sub_service.revoke_subscription(db, subscription)
|
||||
|
||||
if not new_url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to reissue subscription',
|
||||
)
|
||||
|
||||
# Update cooldown timestamp
|
||||
subscription.last_revoke_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Subscription revoked via cabinet API',
|
||||
user_id=user.id,
|
||||
subscription_id=subscription.id,
|
||||
)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'cooldown_seconds': settings.SUBSCRIPTION_REVOKE_COOLDOWN_SECONDS,
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
"""Server/country management endpoints.
|
||||
|
||||
GET /subscription/countries
|
||||
POST /subscription/countries
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query as QueryParam, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
from ...dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from .helpers import resolve_subscription
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('/countries')
|
||||
async def get_available_countries(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'),
|
||||
) -> dict[str, Any]:
|
||||
"""Get available countries/servers for the user."""
|
||||
from app.database.crud.server_squad import get_available_server_squads
|
||||
from app.utils.pricing_utils import apply_percentage_discount, calculate_prorated_price
|
||||
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
|
||||
promo_group_id = user.promo_group_id
|
||||
available_servers = await get_available_server_squads(db, promo_group_id=promo_group_id)
|
||||
|
||||
connected_squads = []
|
||||
days_left = 0
|
||||
if subscription:
|
||||
connected_squads = subscription.connected_squads or []
|
||||
if subscription.end_date:
|
||||
delta = subscription.end_date - datetime.now(UTC)
|
||||
days_left = max(0, delta.days)
|
||||
|
||||
# Get discount from promo group via PricingEngine (respects apply_discounts_to_addons flag)
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
servers_discount_percent = PricingEngine.get_addon_discount_percent(user, 'servers', None)
|
||||
|
||||
countries = []
|
||||
for server in available_servers:
|
||||
base_price = server.price_kopeks
|
||||
|
||||
# Apply discount
|
||||
if servers_discount_percent > 0:
|
||||
discounted_price, _ = apply_percentage_discount(base_price, servers_discount_percent)
|
||||
else:
|
||||
discounted_price = base_price
|
||||
|
||||
# Calculate prorated price if subscription exists
|
||||
prorated_price = discounted_price
|
||||
if subscription and subscription.end_date:
|
||||
prorated_price, _ = calculate_prorated_price(
|
||||
discounted_price,
|
||||
subscription.end_date,
|
||||
)
|
||||
|
||||
countries.append(
|
||||
{
|
||||
'uuid': server.squad_uuid,
|
||||
'name': server.display_name,
|
||||
'country_code': server.country_code,
|
||||
'base_price_kopeks': base_price,
|
||||
'price_kopeks': prorated_price, # Prorated price with discount
|
||||
'price_per_month_kopeks': discounted_price, # Monthly price with discount
|
||||
'price_rubles': prorated_price / 100,
|
||||
'is_available': server.is_available and not server.is_full,
|
||||
'is_connected': server.squad_uuid in connected_squads,
|
||||
'has_discount': servers_discount_percent > 0,
|
||||
'discount_percent': servers_discount_percent,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
'countries': countries,
|
||||
'connected_count': len(connected_squads),
|
||||
'has_subscription': subscription is not None,
|
||||
'days_left': days_left,
|
||||
'discount_percent': servers_discount_percent,
|
||||
}
|
||||
|
||||
|
||||
@router.post('/countries')
|
||||
async def update_countries(
|
||||
request: dict[str, Any],
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'),
|
||||
) -> dict[str, Any]:
|
||||
"""Update subscription countries/servers."""
|
||||
from app.database.crud.server_squad import add_user_to_servers, get_available_server_squads, get_server_ids_by_uuids
|
||||
from app.database.crud.subscription import add_subscription_servers
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import TransactionType
|
||||
from app.utils.pricing_utils import apply_percentage_discount, calculate_prorated_price
|
||||
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='No subscription found',
|
||||
)
|
||||
|
||||
if subscription.is_trial:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Country management is not available for trial subscriptions',
|
||||
)
|
||||
|
||||
selected_countries = request.get('countries', [])
|
||||
if not selected_countries:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='At least one country must be selected',
|
||||
)
|
||||
|
||||
current_countries = subscription.connected_squads or []
|
||||
promo_group_id = user.promo_group_id
|
||||
|
||||
available_servers = await get_available_server_squads(db, promo_group_id=promo_group_id)
|
||||
allowed_country_ids = {server.squad_uuid for server in available_servers}
|
||||
|
||||
# Validate selected countries
|
||||
for country_uuid in selected_countries:
|
||||
if country_uuid not in allowed_country_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Country {country_uuid} is not available',
|
||||
)
|
||||
|
||||
added = [c for c in selected_countries if c not in current_countries]
|
||||
removed = [c for c in current_countries if c not in selected_countries]
|
||||
|
||||
if not added and not removed:
|
||||
return {
|
||||
'message': 'No changes detected',
|
||||
'connected_squads': current_countries,
|
||||
}
|
||||
|
||||
# Lock user row to prevent TOCTOU on promo-offer state
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
# Calculate cost for added servers
|
||||
total_cost = 0
|
||||
added_names = []
|
||||
removed_names = []
|
||||
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
servers_discount_percent = PricingEngine.get_addon_discount_percent(user, 'servers', None)
|
||||
|
||||
added_server_prices = []
|
||||
|
||||
for server in available_servers:
|
||||
if server.squad_uuid in added:
|
||||
server_price_per_month = server.price_kopeks
|
||||
if servers_discount_percent > 0:
|
||||
discounted_per_month, _ = apply_percentage_discount(
|
||||
server_price_per_month,
|
||||
servers_discount_percent,
|
||||
)
|
||||
else:
|
||||
discounted_per_month = server_price_per_month
|
||||
|
||||
charged_price, charged_days = calculate_prorated_price(
|
||||
discounted_per_month,
|
||||
subscription.end_date,
|
||||
)
|
||||
|
||||
total_cost += charged_price
|
||||
added_names.append(server.display_name)
|
||||
added_server_prices.append(charged_price)
|
||||
|
||||
if server.squad_uuid in removed:
|
||||
removed_names.append(server.display_name)
|
||||
|
||||
# Check balance
|
||||
if total_cost > 0 and user.balance_kopeks < total_cost:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail=f'Insufficient balance. Need {total_cost / 100:.2f} RUB, have {user.balance_kopeks / 100:.2f} RUB',
|
||||
)
|
||||
|
||||
# Deduct balance and update subscription
|
||||
if added and total_cost > 0:
|
||||
success = await subtract_user_balance(db, user, total_cost, f'Adding countries: {", ".join(added_names)}')
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to charge balance',
|
||||
)
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=total_cost,
|
||||
description=f'Adding countries to subscription: {", ".join(added_names)}',
|
||||
)
|
||||
|
||||
# Add servers to subscription
|
||||
if added:
|
||||
added_server_ids = await get_server_ids_by_uuids(db, added)
|
||||
if added_server_ids:
|
||||
await add_subscription_servers(db, subscription, added_server_ids, added_server_prices)
|
||||
try:
|
||||
await add_user_to_servers(db, added_server_ids)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка обновления счётчика серверов', error=e)
|
||||
|
||||
# Update connected squads
|
||||
subscription.connected_squads = selected_countries
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
# Sync with RemnaWave
|
||||
try:
|
||||
from app.config import settings
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
_has_panel = (
|
||||
getattr(subscription, 'remnawave_uuid', None)
|
||||
if settings.is_multi_tariff_enabled()
|
||||
else getattr(user, 'remnawave_uuid', None)
|
||||
)
|
||||
if _has_panel:
|
||||
await subscription_service.update_remnawave_user(db, subscription, sync_squads=True)
|
||||
else:
|
||||
await subscription_service.create_remnawave_user(db, subscription)
|
||||
except Exception as e:
|
||||
logger.error('Failed to sync countries with RemnaWave', error=e)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
action='update' if _has_panel else 'create',
|
||||
)
|
||||
|
||||
await db.refresh(subscription)
|
||||
|
||||
return {
|
||||
'message': 'Countries updated successfully',
|
||||
'added': added_names,
|
||||
'removed': removed_names,
|
||||
'amount_paid_kopeks': total_cost,
|
||||
'connected_squads': subscription.connected_squads,
|
||||
}
|
||||
@@ -1,526 +0,0 @@
|
||||
"""Subscription status endpoints.
|
||||
|
||||
GET /subscription — subscription info
|
||||
GET /subscription/connection-link
|
||||
GET /subscription/happ-downloads
|
||||
GET /subscription/app-config
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.database.models import ServerSquad, User
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.system_settings_service import bot_configuration_service
|
||||
|
||||
from ...dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ...schemas.subscription import (
|
||||
ServerInfo,
|
||||
SubscriptionStatusResponse,
|
||||
)
|
||||
from .helpers import _subscription_to_response, resolve_subscription
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('/info', response_model=SubscriptionStatusResponse)
|
||||
async def get_subscription(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'),
|
||||
):
|
||||
"""Get current user's subscription details."""
|
||||
# Reload user from current session to get fresh data
|
||||
# (user object is from different session in get_current_cabinet_user)
|
||||
from app.database.crud.user import get_user_by_id
|
||||
|
||||
fresh_user = await get_user_by_id(db, user.id)
|
||||
|
||||
if not fresh_user:
|
||||
return SubscriptionStatusResponse(has_subscription=False, subscription=None)
|
||||
|
||||
subscription = await resolve_subscription(db, fresh_user, subscription_id)
|
||||
|
||||
if not subscription:
|
||||
# Return 200 with has_subscription: false instead of 404
|
||||
return SubscriptionStatusResponse(has_subscription=False, subscription=None)
|
||||
|
||||
# Load tariff for daily subscription check and tariff name
|
||||
tariff_name = None
|
||||
if subscription.tariff_id:
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff:
|
||||
subscription.tariff = tariff
|
||||
tariff_name = tariff.name
|
||||
|
||||
# Fetch server names for connected squads
|
||||
servers: list[ServerInfo] = []
|
||||
connected_squads = subscription.connected_squads or []
|
||||
if connected_squads:
|
||||
result = await db.execute(select(ServerSquad).where(ServerSquad.squad_uuid.in_(connected_squads)))
|
||||
server_squads = result.scalars().all()
|
||||
servers = [
|
||||
ServerInfo(uuid=sq.squad_uuid, name=sq.display_name, country_code=sq.country_code) for sq in server_squads
|
||||
]
|
||||
|
||||
# Fetch traffic purchases (monthly packages)
|
||||
traffic_purchases_data = []
|
||||
from app.database.models import TrafficPurchase
|
||||
|
||||
now = datetime.now(UTC)
|
||||
purchases_query = (
|
||||
select(TrafficPurchase)
|
||||
.where(TrafficPurchase.subscription_id == subscription.id)
|
||||
.where(TrafficPurchase.expires_at > now)
|
||||
.order_by(TrafficPurchase.expires_at.asc())
|
||||
)
|
||||
purchases_result = await db.execute(purchases_query)
|
||||
purchases = purchases_result.scalars().all()
|
||||
|
||||
for purchase in purchases:
|
||||
time_remaining = purchase.expires_at - now
|
||||
days_remaining = max(0, int(time_remaining.total_seconds() / 86400))
|
||||
total_duration_seconds = (purchase.expires_at - purchase.created_at).total_seconds()
|
||||
elapsed_seconds = (now - purchase.created_at).total_seconds()
|
||||
progress_percent = min(
|
||||
100.0, max(0.0, (elapsed_seconds / total_duration_seconds * 100) if total_duration_seconds > 0 else 0)
|
||||
)
|
||||
|
||||
traffic_purchases_data.append(
|
||||
{
|
||||
'id': purchase.id,
|
||||
'traffic_gb': purchase.traffic_gb,
|
||||
'expires_at': purchase.expires_at,
|
||||
'created_at': purchase.created_at,
|
||||
'days_remaining': days_remaining,
|
||||
'progress_percent': round(progress_percent, 1),
|
||||
}
|
||||
)
|
||||
|
||||
subscription_data = _subscription_to_response(
|
||||
subscription, servers, tariff_name, traffic_purchases_data, user=fresh_user
|
||||
)
|
||||
return SubscriptionStatusResponse(has_subscription=True, subscription=subscription_data)
|
||||
|
||||
|
||||
# ============ Connection Link ============
|
||||
|
||||
|
||||
@router.get('/connection-link')
|
||||
async def get_connection_link(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'),
|
||||
) -> dict[str, Any]:
|
||||
"""Get subscription connection link and instructions."""
|
||||
from app.utils.subscription_utils import (
|
||||
convert_subscription_link_to_happ_scheme,
|
||||
get_display_subscription_link,
|
||||
get_happ_cryptolink_redirect_link,
|
||||
)
|
||||
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='No subscription found',
|
||||
)
|
||||
|
||||
subscription_url = subscription.subscription_url
|
||||
if not subscription_url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Subscription link not yet generated',
|
||||
)
|
||||
|
||||
display_link = get_display_subscription_link(subscription)
|
||||
happ_redirect = get_happ_cryptolink_redirect_link(subscription_url) if settings.is_happ_cryptolink_mode() else None
|
||||
happ_scheme_link = (
|
||||
convert_subscription_link_to_happ_scheme(subscription_url) if settings.is_happ_cryptolink_mode() else None
|
||||
)
|
||||
|
||||
connect_mode = settings.CONNECT_BUTTON_MODE
|
||||
hide_subscription_link = settings.should_hide_subscription_link()
|
||||
|
||||
return {
|
||||
'subscription_url': subscription_url if not hide_subscription_link else None,
|
||||
'display_link': display_link if not hide_subscription_link else None,
|
||||
'happ_redirect_link': happ_redirect,
|
||||
'happ_scheme_link': happ_scheme_link,
|
||||
'connect_mode': connect_mode,
|
||||
'hide_link': hide_subscription_link,
|
||||
'instructions': {
|
||||
'steps': [
|
||||
'Copy the subscription link',
|
||||
'Open your VPN application',
|
||||
"Find 'Add subscription' or 'Import' option",
|
||||
'Paste the copied link',
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ============ hApp Downloads ============
|
||||
|
||||
|
||||
@router.get('/happ-downloads')
|
||||
async def get_happ_downloads(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
) -> dict[str, Any]:
|
||||
"""Get hApp download links for different platforms."""
|
||||
platforms = {
|
||||
'ios': {
|
||||
'name': 'iOS (iPhone/iPad)',
|
||||
'icon': '🍎',
|
||||
'link': settings.get_happ_download_link('ios'),
|
||||
},
|
||||
'android': {
|
||||
'name': 'Android',
|
||||
'icon': '🤖',
|
||||
'link': settings.get_happ_download_link('android'),
|
||||
},
|
||||
'macos': {
|
||||
'name': 'macOS',
|
||||
'icon': '🖥️',
|
||||
'link': settings.get_happ_download_link('macos'),
|
||||
},
|
||||
'windows': {
|
||||
'name': 'Windows',
|
||||
'icon': '💻',
|
||||
'link': settings.get_happ_download_link('windows'),
|
||||
},
|
||||
}
|
||||
|
||||
# Filter out platforms without links
|
||||
available_platforms = {k: v for k, v in platforms.items() if v['link']}
|
||||
|
||||
return {
|
||||
'platforms': available_platforms,
|
||||
'happ_enabled': bool(available_platforms),
|
||||
}
|
||||
|
||||
|
||||
# ============ App Config for Connection ============
|
||||
|
||||
|
||||
def _get_remnawave_config_uuid() -> str | None:
|
||||
"""Get RemnaWave config UUID from system settings or env."""
|
||||
try:
|
||||
return bot_configuration_service.get_current_value('CABINET_REMNA_SUB_CONFIG')
|
||||
except Exception:
|
||||
return settings.CABINET_REMNA_SUB_CONFIG
|
||||
|
||||
|
||||
def _extract_scheme_from_buttons(buttons: list[dict[str, Any]]) -> tuple[str, bool]:
|
||||
"""Extract URL scheme from buttons list.
|
||||
|
||||
Returns:
|
||||
Tuple of (scheme, uses_crypto_link).
|
||||
uses_crypto_link=True when the template is {{HAPP_CRYPT4_LINK}},
|
||||
meaning subscription_crypto_link should be used as payload.
|
||||
"""
|
||||
for btn in buttons:
|
||||
if not isinstance(btn, dict):
|
||||
continue
|
||||
link = btn.get('link', '') or btn.get('url', '') or btn.get('buttonLink', '')
|
||||
if not link:
|
||||
continue
|
||||
link_upper = link.upper()
|
||||
|
||||
# Check for {{HAPP_CRYPT4_LINK}} -- uses crypto link as payload
|
||||
if '{{HAPP_CRYPT4_LINK}}' in link_upper or 'HAPP_CRYPT4_LINK' in link_upper:
|
||||
scheme = re.sub(r'\{\{HAPP_CRYPT4_LINK\}\}', '', link, flags=re.IGNORECASE)
|
||||
if scheme and '://' in scheme:
|
||||
return scheme, True
|
||||
|
||||
# Check for {{SUBSCRIPTION_LINK}} -- uses plain subscription_url as payload
|
||||
if '{{SUBSCRIPTION_LINK}}' in link_upper or 'SUBSCRIPTION_LINK' in link_upper:
|
||||
scheme = re.sub(r'\{\{SUBSCRIPTION_LINK\}\}', '', link, flags=re.IGNORECASE)
|
||||
if scheme and '://' in scheme:
|
||||
return scheme, False
|
||||
|
||||
# Also check for type="subscriptionLink" buttons with custom schemes
|
||||
btn_type = btn.get('type', '')
|
||||
if btn_type == 'subscriptionLink' and '://' in link and not link.startswith('http'):
|
||||
scheme = link.split('{{')[0] if '{{' in link else link
|
||||
if scheme and '://' in scheme:
|
||||
return scheme, False
|
||||
return '', False
|
||||
|
||||
|
||||
def _get_url_scheme_for_app(app: dict[str, Any]) -> tuple[str, bool]:
|
||||
"""Get URL scheme for app - from config, buttons, or fallback by name.
|
||||
|
||||
Returns:
|
||||
Tuple of (scheme, uses_crypto_link).
|
||||
uses_crypto_link=True means the app template uses {{HAPP_CRYPT4_LINK}},
|
||||
so subscription_crypto_link should be used as the deep link payload.
|
||||
"""
|
||||
# 1. Check urlScheme field (cabinet format stores usesCryptoLink alongside)
|
||||
scheme = str(app.get('urlScheme', '')).strip()
|
||||
if scheme:
|
||||
uses_crypto = bool(app.get('usesCryptoLink', False))
|
||||
return scheme, uses_crypto
|
||||
|
||||
# 2. Extract from buttons in blocks (RemnaWave format)
|
||||
blocks = app.get('blocks', [])
|
||||
for block in blocks:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
buttons = block.get('buttons', [])
|
||||
scheme, uses_crypto = _extract_scheme_from_buttons(buttons)
|
||||
if scheme:
|
||||
return scheme, uses_crypto
|
||||
|
||||
# 3. Check buttons directly in app (alternative structure)
|
||||
direct_buttons = app.get('buttons', [])
|
||||
if direct_buttons:
|
||||
scheme, uses_crypto = _extract_scheme_from_buttons(direct_buttons)
|
||||
if scheme:
|
||||
return scheme, uses_crypto
|
||||
|
||||
# No scheme found
|
||||
logger.debug(
|
||||
'_get_url_scheme_for_app: No scheme found for app has blocks: has buttons: has urlScheme',
|
||||
get=app.get('name'),
|
||||
get_2=bool(app.get('blocks')),
|
||||
get_3=bool(app.get('buttons')),
|
||||
get_4=bool(app.get('urlScheme')),
|
||||
)
|
||||
return '', False
|
||||
|
||||
|
||||
async def _load_app_config_async() -> dict[str, Any] | None:
|
||||
"""Load app config from RemnaWave API (if configured).
|
||||
|
||||
Returns None when no Remnawave config is set or API fails.
|
||||
"""
|
||||
remnawave_uuid = _get_remnawave_config_uuid()
|
||||
|
||||
if remnawave_uuid:
|
||||
try:
|
||||
service = RemnaWaveService()
|
||||
async with service.get_api_client() as api:
|
||||
config = await api.get_subscription_page_config(remnawave_uuid)
|
||||
if config and config.config:
|
||||
logger.debug('Loaded app config from RemnaWave', remnawave_uuid=remnawave_uuid)
|
||||
raw = dict(config.config)
|
||||
raw['_isRemnawave'] = True
|
||||
return raw
|
||||
except Exception as e:
|
||||
logger.warning('Failed to load RemnaWave config', error=e)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _create_deep_link(
|
||||
app: dict[str, Any], subscription_url: str, subscription_crypto_link: str | None = None
|
||||
) -> str | None:
|
||||
"""Create deep link for app with subscription URL.
|
||||
|
||||
Uses urlScheme from RemnaWave config (e.g. "happ://add/", "v2rayng://install-config?url=")
|
||||
combined with the appropriate payload URL.
|
||||
|
||||
Two Happ schemes exist in RemnaWave:
|
||||
- happ://add/{{SUBSCRIPTION_LINK}} -> uses plain subscription_url
|
||||
- happ://crypt4/{{HAPP_CRYPT4_LINK}} -> uses subscription_crypto_link
|
||||
"""
|
||||
if not isinstance(app, dict):
|
||||
return None
|
||||
|
||||
if not subscription_url and not subscription_crypto_link:
|
||||
return None
|
||||
|
||||
scheme, uses_crypto = _get_url_scheme_for_app(app)
|
||||
if not scheme:
|
||||
logger.debug('_create_deep_link: no urlScheme for app', get=app.get('name', 'unknown'))
|
||||
return None
|
||||
|
||||
# Pick the correct payload based on which template the app uses
|
||||
if uses_crypto:
|
||||
if not subscription_crypto_link:
|
||||
logger.debug(
|
||||
'_create_deep_link: app requires crypto link but none available', get=app.get('name', 'unknown')
|
||||
)
|
||||
return None
|
||||
payload = subscription_crypto_link
|
||||
else:
|
||||
if not subscription_url:
|
||||
logger.debug(
|
||||
'_create_deep_link: app requires subscription_url but none available', get=app.get('name', 'unknown')
|
||||
)
|
||||
return None
|
||||
payload = subscription_url
|
||||
|
||||
if app.get('isNeedBase64Encoding'):
|
||||
try:
|
||||
payload = base64.b64encode(payload.encode('utf-8')).decode('utf-8')
|
||||
except Exception as e:
|
||||
logger.warning('Failed to encode payload to base64', error=e)
|
||||
|
||||
return f'{scheme}{payload}'
|
||||
|
||||
|
||||
def _resolve_button_url(
|
||||
url: str,
|
||||
subscription_url: str | None,
|
||||
subscription_crypto_link: str | None,
|
||||
) -> str:
|
||||
"""Resolve template variables in button URLs.
|
||||
|
||||
Matches remnawave/subscription-page frontend TemplateEngine:
|
||||
- {{SUBSCRIPTION_LINK}} -> plain subscription URL
|
||||
- {{HAPP_CRYPT3_LINK}} -> crypto link
|
||||
- {{HAPP_CRYPT4_LINK}} -> crypto link
|
||||
"""
|
||||
if not url:
|
||||
return url
|
||||
result = url
|
||||
if subscription_url:
|
||||
result = result.replace('{{SUBSCRIPTION_LINK}}', subscription_url)
|
||||
if subscription_crypto_link:
|
||||
result = result.replace('{{HAPP_CRYPT3_LINK}}', subscription_crypto_link)
|
||||
result = result.replace('{{HAPP_CRYPT4_LINK}}', subscription_crypto_link)
|
||||
return result
|
||||
|
||||
|
||||
@router.get('/app-config')
|
||||
async def get_app_config(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'),
|
||||
) -> dict[str, Any]:
|
||||
"""Get app configuration for connection with deep links."""
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
|
||||
subscription_url = None
|
||||
subscription_crypto_link = None
|
||||
if subscription:
|
||||
subscription_url = subscription.subscription_url
|
||||
subscription_crypto_link = subscription.subscription_crypto_link
|
||||
|
||||
# Generate crypto link on the fly if subscription_url exists but crypto link is missing.
|
||||
# This covers synced users where enrich_happ_links was not called.
|
||||
if subscription_url and not subscription_crypto_link:
|
||||
try:
|
||||
service = RemnaWaveService()
|
||||
async with service.get_api_client() as api:
|
||||
encrypted = await api.encrypt_happ_crypto_link(subscription_url)
|
||||
if encrypted:
|
||||
subscription_crypto_link = encrypted
|
||||
if subscription:
|
||||
subscription.subscription_crypto_link = encrypted
|
||||
await db.commit()
|
||||
logger.info(
|
||||
'Generated and saved crypto link for user',
|
||||
user_id=user.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug('Could not generate crypto link', error=e)
|
||||
|
||||
config = await _load_app_config_async()
|
||||
|
||||
if not config:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='App configuration not set up.',
|
||||
)
|
||||
|
||||
config.pop('_isRemnawave', None)
|
||||
hide_link = settings.should_hide_subscription_link()
|
||||
|
||||
# Build platformNames from displayName of each platform
|
||||
platform_names: dict[str, Any] = {}
|
||||
for pk, pd in config.get('platforms', {}).items():
|
||||
if isinstance(pd, dict) and 'displayName' in pd:
|
||||
platform_names[pk] = pd['displayName']
|
||||
fallback_names = {
|
||||
'ios': {'en': 'iPhone/iPad'},
|
||||
'android': {'en': 'Android'},
|
||||
'macos': {'en': 'macOS'},
|
||||
'windows': {'en': 'Windows'},
|
||||
'linux': {'en': 'Linux'},
|
||||
'androidTV': {'en': 'Android TV'},
|
||||
'appleTV': {'en': 'Apple TV'},
|
||||
}
|
||||
for k, v in fallback_names.items():
|
||||
if k not in platform_names:
|
||||
platform_names[k] = v
|
||||
|
||||
# Serve original blocks/svgLibrary enriched with deep links and resolved URLs.
|
||||
platforms: dict[str, Any] = {}
|
||||
for platform_key, platform_data in config.get('platforms', {}).items():
|
||||
if not isinstance(platform_data, dict):
|
||||
continue
|
||||
apps = platform_data.get('apps', [])
|
||||
if not isinstance(apps, list):
|
||||
continue
|
||||
|
||||
enriched_apps = []
|
||||
for app in apps:
|
||||
if not isinstance(app, dict):
|
||||
continue
|
||||
|
||||
# Generate deep link
|
||||
deep_link = None
|
||||
if subscription_url or subscription_crypto_link:
|
||||
deep_link = _create_deep_link(app, subscription_url, subscription_crypto_link)
|
||||
app['deepLink'] = deep_link
|
||||
|
||||
# Resolve templates only for subscriptionLink and copyButton (not external)
|
||||
for block in app.get('blocks', []):
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
for btn in block.get('buttons', []):
|
||||
if not isinstance(btn, dict):
|
||||
continue
|
||||
btn_type = btn.get('type', '')
|
||||
if btn_type in ('subscriptionLink', 'copyButton'):
|
||||
url = btn.get('url', '') or btn.get('link', '')
|
||||
if url and '{{' in url:
|
||||
resolved = _resolve_button_url(
|
||||
url,
|
||||
subscription_url,
|
||||
subscription_crypto_link,
|
||||
)
|
||||
# Only set resolvedUrl if ALL templates were resolved;
|
||||
# otherwise let the frontend fall through to deepLink/subscriptionUrl
|
||||
if '{{' not in resolved:
|
||||
btn['resolvedUrl'] = resolved
|
||||
|
||||
enriched_apps.append(app)
|
||||
|
||||
if enriched_apps:
|
||||
platform_output = {k: v for k, v in platform_data.items() if k != 'apps'}
|
||||
platform_output['apps'] = enriched_apps
|
||||
platforms[platform_key] = platform_output
|
||||
|
||||
return {
|
||||
'isRemnawave': True,
|
||||
'platforms': platforms,
|
||||
'svgLibrary': config.get('svgLibrary', {}),
|
||||
'baseTranslations': config.get('baseTranslations'),
|
||||
'baseSettings': config.get('baseSettings'),
|
||||
'uiConfig': config.get('uiConfig', {}),
|
||||
'platformNames': platform_names,
|
||||
'hasSubscription': bool(subscription_url or subscription_crypto_link),
|
||||
'subscriptionUrl': subscription_url,
|
||||
'subscriptionCryptoLink': subscription_crypto_link,
|
||||
'hideLink': hide_link,
|
||||
'branding': config.get('brandingSettings', {}),
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user