Compare commits
146 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28c953c5ae | |||
| 685305bff2 | |||
| 927eb1d240 | |||
| c767473184 | |||
| 744e5c1453 | |||
| 5eb581c68e | |||
| cf53801408 | |||
| 4e59d0a071 | |||
| c31de445b9 | |||
| cc3f78c2f8 | |||
| 5eae547cb2 | |||
| 7e8e0c1617 | |||
| 250b8d95fb | |||
| 28e868fb09 | |||
| 45e9a29b4e | |||
| 8a52b34760 | |||
| 464df9689e | |||
| 6191a7a395 | |||
| 233f6c3490 | |||
| 0e5b115d64 | |||
| 69bf99f3a6 | |||
| dae5c07318 | |||
| d53bf4ae29 | |||
| 50dd5a5fb3 | |||
| 9595026d3d | |||
| 571018982f | |||
| cfdfe1ccd1 | |||
| d9f0d6496d | |||
| 0eba4cfc8f | |||
| 3eb84338a4 | |||
| 127a609d6b | |||
| 528944f649 | |||
| 48fa739ca7 | |||
| 07e50f449f | |||
| 47433d905e | |||
| b096003683 | |||
| 5814bbc920 | |||
| fdffe7ae35 | |||
| 917fa84838 | |||
| 9631f340c1 | |||
| 61fe7f0be4 | |||
| a8c54c4d3b | |||
| db7e6cf8f8 | |||
| 9690436b9f | |||
| fefc46e5c4 | |||
| 14fc1f58af | |||
| f933992883 | |||
| fdc382f309 | |||
| 0ef3e32c15 | |||
| 9c870ffc70 | |||
| 6ce87698bc | |||
| 60a38d3ea6 | |||
| 5355f41bef | |||
| 0fac84aa7d | |||
| b4e472b873 | |||
| 6c0c3f1b79 | |||
| 203405438f | |||
| a38c12eaa6 | |||
| e48ebca91e | |||
| e32bf5f2c4 | |||
| fbb7d6c4ab | |||
| 29f967469f | |||
| 541d3c903d | |||
| f19e2b2a34 | |||
| 3d0050139c | |||
| 0b677e4205 | |||
| 71dac493d8 | |||
| fc1528532e | |||
| 48cb19170b | |||
| 1af1919a14 | |||
| 5d864d0286 | |||
| a448a2c450 | |||
| e301d49657 | |||
| 738216cf9f | |||
| 7b6f646d7e | |||
| 3150349ffa | |||
| b50478eda0 | |||
| cff00eb515 | |||
| a981bf2ae0 | |||
| 031c2b683b | |||
| 72d2501be5 | |||
| 69ff87ab31 | |||
| e4fce0f430 | |||
| 5fb0699dcb | |||
| 965ae9d1d8 | |||
| 23b64ffb2a | |||
| 9868c5de0f | |||
| aeaaa54920 | |||
| fb25032284 | |||
| 3e0661da39 | |||
| 7ab9fe9ad2 | |||
| 7c846c2f83 | |||
| 163f55ec14 | |||
| 2c8e67ac82 | |||
| e9ce583eb2 | |||
| 416908aea4 | |||
| 6223a5d63a | |||
| 53a10c0640 | |||
| 7c2408209d | |||
| 9fbc3a8312 | |||
| dff08cbad2 | |||
| a2379b9be1 | |||
| 12dc9ccbaa | |||
| 4b74ae12ad | |||
| 258a4a5cb4 | |||
| 2cd2147464 | |||
| c5efc7ce06 | |||
| 9cd5d8e0b9 | |||
| 31d538fbcf | |||
| 2156f630dc | |||
| 917ca69b1d | |||
| d524088bb8 | |||
| 9bd1944ba3 | |||
| c41979bda6 | |||
| 0813d585d2 | |||
| 3f74005068 | |||
| 6b69ec750e | |||
| 5a5a18d80d | |||
| 4bebff5c4a | |||
| e2ae7dd34d | |||
| e15728e369 | |||
| ecaf270e04 | |||
| 08e864e5ea | |||
| 97655b8616 | |||
| 800d589afa | |||
| ff51a984ef | |||
| a4072237cc | |||
| ac83273a22 | |||
| 073d96fb27 | |||
| 8e6082ce15 | |||
| 08692145d2 | |||
| 56cc8bacf2 | |||
| 096b4d4fe3 | |||
| 9dd3299744 | |||
| bc19ec32bb | |||
| 720f0ecb60 | |||
| a9fd4c2466 | |||
| 5aa9b6ddb3 | |||
| 180cba4561 | |||
| 25dc7ff624 | |||
| 1233d38fe1 | |||
| d60ebaef41 | |||
| dd86014667 | |||
| bb8beff511 | |||
| 8843d86d9b | |||
| 449f82d538 |
+71
-1
@@ -3,11 +3,43 @@
|
||||
# ===============================================
|
||||
|
||||
# ===== TELEGRAM BOT =====
|
||||
# Токен бота от @BotFather
|
||||
# ВАЖНО: Также используется для авторизации виджета личного кабинета (Cabinet WebApp)
|
||||
# через Telegram.WebApp.initData
|
||||
BOT_TOKEN=
|
||||
ADMIN_IDS=
|
||||
# Ссылка на поддержку: Telegram username (например, @support) или полный URL
|
||||
SUPPORT_USERNAME=@support
|
||||
|
||||
# ===== ЛИЧНЫЙ КАБИНЕТ (CABINET) =====
|
||||
# Включить личный кабинет пользователя (веб-интерфейс для управления подпиской)
|
||||
CABINET_ENABLED=false
|
||||
# Секретный ключ для JWT токенов (если не указан, используется BOT_TOKEN)
|
||||
CABINET_JWT_SECRET=
|
||||
# Время жизни access token в минутах (по умолчанию 15)
|
||||
CABINET_ACCESS_TOKEN_EXPIRE_MINUTES=15
|
||||
# Время жизни refresh token в днях (по умолчанию 7)
|
||||
CABINET_REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
# Разрешенные origins для CORS (через запятую, например: https://cabinet.example.com)
|
||||
CABINET_ALLOWED_ORIGINS=
|
||||
# Включить верификацию email (требует настройки SMTP)
|
||||
CABINET_EMAIL_VERIFICATION_ENABLED=false
|
||||
# Время жизни токена верификации email в часах
|
||||
CABINET_EMAIL_VERIFICATION_EXPIRE_HOURS=24
|
||||
# Время жизни токена сброса пароля в часах
|
||||
CABINET_PASSWORD_RESET_EXPIRE_HOURS=1
|
||||
|
||||
# ===== SMTP НАСТРОЙКИ (для email в личном кабинете) =====
|
||||
# SMTP сервер (например: smtp.gmail.com, smtp.yandex.ru)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
# Email отправителя (если не указан, используется SMTP_USER)
|
||||
SMTP_FROM_EMAIL=
|
||||
SMTP_FROM_NAME=VPN Service
|
||||
# Использовать TLS шифрование
|
||||
SMTP_USE_TLS=true
|
||||
|
||||
# Уведомления администраторов
|
||||
ADMIN_NOTIFICATIONS_ENABLED=true
|
||||
@@ -99,10 +131,21 @@ REMNAWAVE_USER_USERNAME_TEMPLATE="user_{telegram_id}"
|
||||
REMNAWAVE_USER_DELETE_MODE=delete
|
||||
|
||||
# ========= ПОДПИСКИ =========
|
||||
|
||||
# ===== РЕЖИМ ПРОДАЖ =====
|
||||
# Режим продаж подписок:
|
||||
# "classic" - классический режим (выбор серверов, трафика, устройств, периода отдельно)
|
||||
# "tariffs" - режим тарифов (готовые пакеты с фиксированными параметрами)
|
||||
SALES_MODE=classic
|
||||
|
||||
# ===== ТРИАЛ ПОДПИСКА =====
|
||||
TRIAL_DURATION_DAYS=3
|
||||
TRIAL_TRAFFIC_LIMIT_GB=10
|
||||
TRIAL_DEVICE_LIMIT=1
|
||||
# ID тарифа для триала в режиме тарифов (0 = использовать стандартные настройки триала)
|
||||
# Если указан ID тарифа, параметры триала берутся из тарифа (traffic_limit_gb, device_limit, allowed_squads)
|
||||
# Длительность триала всё равно берётся из TRIAL_DURATION_DAYS
|
||||
TRIAL_TARIFF_ID=0
|
||||
# Платный триал: если TRIAL_ACTIVATION_PRICE > 0, триал становится платным
|
||||
# Цена в копейках (1000 = 10 рублей). Пользователь может оплатить триал любым методом оплаты.
|
||||
# TRIAL_PAYMENT_ENABLED опционален (для обратной совместимости)
|
||||
@@ -262,9 +305,15 @@ YOOKASSA_VAT_CODE=1
|
||||
# 1 - НДС не облагается
|
||||
# 2 - НДС 0%
|
||||
# 3 - НДС 10%
|
||||
# 4 - НДС 20%/22%
|
||||
# 4 - НДС 20%
|
||||
# 5 - НДС 10/110
|
||||
# 6 - НДС 20/120
|
||||
# 7 - НДС 5%
|
||||
# 8 - НДС 7%
|
||||
# 9 - НДС 5/105
|
||||
# 10 - НДС 7/107
|
||||
# 11 - НДС 22%
|
||||
# 12 - НДС 22/122
|
||||
|
||||
YOOKASSA_PAYMENT_MODE=full_payment
|
||||
# Способы расчета:
|
||||
@@ -413,6 +462,27 @@ PLATEGA_WEBHOOK_PATH=/platega-webhook
|
||||
PLATEGA_WEBHOOK_HOST=0.0.0.0
|
||||
PLATEGA_WEBHOOK_PORT=8086
|
||||
|
||||
# ===== FREEKASSA =====
|
||||
FREEKASSA_ENABLED=false
|
||||
FREEKASSA_SHOP_ID=
|
||||
FREEKASSA_API_KEY=
|
||||
# Секретное слово 1 (для формы оплаты)
|
||||
FREEKASSA_SECRET_WORD_1=
|
||||
# Секретное слово 2 (для webhook)
|
||||
FREEKASSA_SECRET_WORD_2=
|
||||
FREEKASSA_DISPLAY_NAME=Freekassa
|
||||
FREEKASSA_CURRENCY=RUB
|
||||
FREEKASSA_MIN_AMOUNT_KOPEKS=10000
|
||||
FREEKASSA_MAX_AMOUNT_KOPEKS=100000000
|
||||
FREEKASSA_PAYMENT_TIMEOUT_SECONDS=3600
|
||||
FREEKASSA_WEBHOOK_PATH=/freekassa-webhook
|
||||
FREEKASSA_WEBHOOK_HOST=0.0.0.0
|
||||
FREEKASSA_WEBHOOK_PORT=8088
|
||||
# Способ оплаты: пусто = форма выбора, 42 = обычный СБП, 44 = NSPK СБП
|
||||
FREEKASSA_PAYMENT_SYSTEM_ID=
|
||||
# Использовать API для создания заказов (обязательно для NSPK СБП)
|
||||
FREEKASSA_USE_API=false
|
||||
|
||||
# ===== ИНТЕРФЕЙС И UX =====
|
||||
|
||||
# Включить логотип для всех сообщений (true - с изображением, false - только текст)
|
||||
|
||||
@@ -36,15 +36,15 @@ jobs:
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.9.4-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.5-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.9.4-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.5-dev-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v2.9.4-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.5-pr-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
@@ -49,13 +49,13 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.9.4-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.5-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.9.4-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.5-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.9.4-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.5-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.9.4"
|
||||
ARG VERSION="v2.9.5"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ from app.handlers.admin import (
|
||||
faq as admin_faq,
|
||||
payments as admin_payments,
|
||||
trials as admin_trials,
|
||||
tariffs as admin_tariffs,
|
||||
)
|
||||
from app.handlers import contests as user_contests
|
||||
from app.handlers.stars_payments import register_stars_handlers
|
||||
@@ -190,6 +191,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_faq.register_handlers(dp)
|
||||
admin_payments.register_handlers(dp)
|
||||
admin_trials.register_handlers(dp)
|
||||
admin_tariffs.register_handlers(dp)
|
||||
admin_bulk_ban.register_bulk_ban_handlers(dp)
|
||||
admin_blacklist.register_blacklist_handlers(dp)
|
||||
common.register_handlers(dp)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
Cabinet module - Personal Account for VPN Bot users.
|
||||
|
||||
This module provides:
|
||||
- JWT-based authentication (Telegram + Email)
|
||||
- Subscription management
|
||||
- Balance & payments
|
||||
- Referral program
|
||||
- Support tickets
|
||||
"""
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Cabinet authentication module."""
|
||||
|
||||
from .password_utils import hash_password, verify_password
|
||||
from .jwt_handler import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
get_token_payload,
|
||||
)
|
||||
from .telegram_auth import validate_telegram_login_widget, validate_telegram_init_data
|
||||
|
||||
__all__ = [
|
||||
"hash_password",
|
||||
"verify_password",
|
||||
"create_access_token",
|
||||
"create_refresh_token",
|
||||
"decode_token",
|
||||
"get_token_payload",
|
||||
"validate_telegram_login_widget",
|
||||
"validate_telegram_init_data",
|
||||
]
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Email verification token generation and validation."""
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def generate_verification_token() -> str:
|
||||
"""
|
||||
Generate a secure random verification token.
|
||||
|
||||
Returns:
|
||||
32-character hex token string
|
||||
"""
|
||||
return secrets.token_hex(32)
|
||||
|
||||
|
||||
def generate_password_reset_token() -> str:
|
||||
"""
|
||||
Generate a secure random password reset token.
|
||||
|
||||
Returns:
|
||||
32-character hex token string
|
||||
"""
|
||||
return secrets.token_hex(32)
|
||||
|
||||
|
||||
def get_verification_expires_at() -> datetime:
|
||||
"""
|
||||
Get the expiration datetime for a verification token.
|
||||
|
||||
Returns:
|
||||
Datetime when the verification token expires
|
||||
"""
|
||||
hours = settings.get_cabinet_email_verification_expire_hours()
|
||||
return datetime.utcnow() + timedelta(hours=hours)
|
||||
|
||||
|
||||
def get_password_reset_expires_at() -> datetime:
|
||||
"""
|
||||
Get the expiration datetime for a password reset token.
|
||||
|
||||
Returns:
|
||||
Datetime when the password reset token expires
|
||||
"""
|
||||
hours = settings.get_cabinet_password_reset_expire_hours()
|
||||
return datetime.utcnow() + timedelta(hours=hours)
|
||||
|
||||
|
||||
def is_token_expired(expires_at: Optional[datetime]) -> bool:
|
||||
"""
|
||||
Check if a token has expired.
|
||||
|
||||
Args:
|
||||
expires_at: Token expiration datetime
|
||||
|
||||
Returns:
|
||||
True if expired or no expiration set, False otherwise
|
||||
"""
|
||||
if expires_at is None:
|
||||
return True
|
||||
return datetime.utcnow() > expires_at
|
||||
@@ -0,0 +1,106 @@
|
||||
"""JWT token handling for cabinet authentication."""
|
||||
|
||||
import jwt
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from app.config import settings
|
||||
|
||||
JWT_ALGORITHM = "HS256"
|
||||
|
||||
|
||||
def create_access_token(user_id: int, telegram_id: int) -> str:
|
||||
"""
|
||||
Create a short-lived access token.
|
||||
|
||||
Args:
|
||||
user_id: Database user ID
|
||||
telegram_id: Telegram user ID
|
||||
|
||||
Returns:
|
||||
Encoded JWT access token
|
||||
"""
|
||||
expire_minutes = settings.get_cabinet_access_token_expire_minutes()
|
||||
expires = datetime.utcnow() + timedelta(minutes=expire_minutes)
|
||||
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"telegram_id": telegram_id,
|
||||
"type": "access",
|
||||
"exp": expires,
|
||||
"iat": datetime.utcnow(),
|
||||
}
|
||||
|
||||
secret = settings.get_cabinet_jwt_secret()
|
||||
return jwt.encode(payload, secret, algorithm=JWT_ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(user_id: int) -> str:
|
||||
"""
|
||||
Create a long-lived refresh token.
|
||||
|
||||
Args:
|
||||
user_id: Database user ID
|
||||
|
||||
Returns:
|
||||
Encoded JWT refresh token
|
||||
"""
|
||||
expire_days = settings.get_cabinet_refresh_token_expire_days()
|
||||
expires = datetime.utcnow() + timedelta(days=expire_days)
|
||||
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"type": "refresh",
|
||||
"exp": expires,
|
||||
"iat": datetime.utcnow(),
|
||||
}
|
||||
|
||||
secret = settings.get_cabinet_jwt_secret()
|
||||
return jwt.encode(payload, secret, algorithm=JWT_ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Decode and validate a JWT token.
|
||||
|
||||
Args:
|
||||
token: JWT token string
|
||||
|
||||
Returns:
|
||||
Decoded payload dict or None if invalid/expired
|
||||
"""
|
||||
try:
|
||||
secret = settings.get_cabinet_jwt_secret()
|
||||
return jwt.decode(token, secret, algorithms=[JWT_ALGORITHM])
|
||||
except jwt.ExpiredSignatureError:
|
||||
return None
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
|
||||
def get_token_payload(token: str, expected_type: str = "access") -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Decode token and verify its type.
|
||||
|
||||
Args:
|
||||
token: JWT token string
|
||||
expected_type: Expected token type ("access" or "refresh")
|
||||
|
||||
Returns:
|
||||
Decoded payload dict or None if invalid/expired/wrong type
|
||||
"""
|
||||
payload = decode_token(token)
|
||||
|
||||
if not payload:
|
||||
return None
|
||||
|
||||
if payload.get("type") != expected_type:
|
||||
return None
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
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.utcnow() + timedelta(days=expire_days)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Password hashing utilities using bcrypt."""
|
||||
|
||||
import bcrypt
|
||||
|
||||
BCRYPT_ROUNDS = 12
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""
|
||||
Hash a password using bcrypt.
|
||||
|
||||
Args:
|
||||
password: Plain text password
|
||||
|
||||
Returns:
|
||||
Hashed password string
|
||||
"""
|
||||
password_bytes = password.encode("utf-8")
|
||||
salt = bcrypt.gensalt(rounds=BCRYPT_ROUNDS)
|
||||
hashed = bcrypt.hashpw(password_bytes, salt)
|
||||
return hashed.decode("utf-8")
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
"""
|
||||
Verify a password against its hash.
|
||||
|
||||
Args:
|
||||
password: Plain text password to verify
|
||||
password_hash: Previously hashed password
|
||||
|
||||
Returns:
|
||||
True if password matches, False otherwise
|
||||
"""
|
||||
try:
|
||||
password_bytes = password.encode("utf-8")
|
||||
hash_bytes = password_hash.encode("utf-8")
|
||||
return bcrypt.checkpw(password_bytes, hash_bytes)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Telegram authentication validation for cabinet."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional
|
||||
from urllib.parse import parse_qsl, unquote
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def validate_telegram_login_widget(data: Dict[str, Any], max_age_seconds: int = 86400) -> bool:
|
||||
"""
|
||||
Validate Telegram Login Widget data.
|
||||
|
||||
https://core.telegram.org/widgets/login#checking-authorization
|
||||
|
||||
Args:
|
||||
data: Dictionary with Telegram login data (id, first_name, auth_date, hash, etc.)
|
||||
max_age_seconds: Maximum allowed age of auth_date (default 24 hours)
|
||||
|
||||
Returns:
|
||||
True if data is valid, False otherwise
|
||||
"""
|
||||
auth_data = data.copy()
|
||||
check_hash = auth_data.pop("hash", None)
|
||||
|
||||
if not check_hash:
|
||||
return False
|
||||
|
||||
# Check auth_date is not too old
|
||||
auth_date = auth_data.get("auth_date")
|
||||
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
|
||||
|
||||
# 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]
|
||||
data_check_string = "\n".join(data_check_arr)
|
||||
|
||||
# Create secret key from bot token using SHA256
|
||||
bot_token = settings.BOT_TOKEN
|
||||
secret_key = hashlib.sha256(bot_token.encode()).digest()
|
||||
|
||||
# Calculate expected hash
|
||||
calculated_hash = hmac.new(
|
||||
secret_key,
|
||||
data_check_string.encode(),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
return hmac.compare_digest(calculated_hash, check_hash)
|
||||
|
||||
|
||||
def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Validate Telegram WebApp initData.
|
||||
|
||||
https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
|
||||
|
||||
Args:
|
||||
init_data: Raw initData string from Telegram WebApp
|
||||
max_age_seconds: Maximum allowed age of auth_date (default 24 hours)
|
||||
|
||||
Returns:
|
||||
Parsed user data dict if valid, None otherwise
|
||||
"""
|
||||
try:
|
||||
# Parse the init_data string
|
||||
parsed = dict(parse_qsl(init_data, keep_blank_values=True))
|
||||
|
||||
received_hash = parsed.pop("hash", None)
|
||||
if not received_hash:
|
||||
return None
|
||||
|
||||
# Check auth_date is not too old
|
||||
auth_date = parsed.get("auth_date")
|
||||
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
|
||||
|
||||
# Build data-check-string
|
||||
data_check_arr = [f"{k}={v}" for k, v in sorted(parsed.items())]
|
||||
data_check_string = "\n".join(data_check_arr)
|
||||
|
||||
# Create secret key: HMAC_SHA256(bot_token, "WebAppData")
|
||||
bot_token = settings.BOT_TOKEN
|
||||
secret_key = hmac.new(
|
||||
b"WebAppData",
|
||||
bot_token.encode(),
|
||||
hashlib.sha256
|
||||
).digest()
|
||||
|
||||
# Calculate expected hash
|
||||
calculated_hash = hmac.new(
|
||||
secret_key,
|
||||
data_check_string.encode(),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
if not hmac.compare_digest(calculated_hash, received_hash):
|
||||
return None
|
||||
|
||||
# Parse user data from the validated data
|
||||
user_data_str = parsed.get("user")
|
||||
if user_data_str:
|
||||
user_data = json.loads(unquote(user_data_str))
|
||||
return user_data
|
||||
|
||||
return parsed
|
||||
|
||||
except (ValueError, TypeError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def extract_telegram_user_from_init_data(init_data: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Extract and validate user info from Telegram WebApp initData.
|
||||
|
||||
Args:
|
||||
init_data: Raw initData string from Telegram WebApp
|
||||
|
||||
Returns:
|
||||
User data dict with id, first_name, last_name, username, etc. or None if invalid
|
||||
"""
|
||||
return validate_telegram_init_data(init_data)
|
||||
@@ -0,0 +1,140 @@
|
||||
"""FastAPI dependencies for cabinet module."""
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Optional
|
||||
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import User
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.config import settings
|
||||
from .auth.jwt_handler import get_token_payload
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def get_cabinet_db() -> AsyncSession:
|
||||
"""Get database session for cabinet operations."""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def get_current_cabinet_user(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> User:
|
||||
"""
|
||||
Get current authenticated cabinet user from JWT token.
|
||||
|
||||
Args:
|
||||
credentials: HTTP Bearer credentials
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Authenticated User object
|
||||
|
||||
Raises:
|
||||
HTTPException: If token is invalid, expired, or user not found
|
||||
"""
|
||||
if not credentials:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentication required",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
token = credentials.credentials
|
||||
payload = get_token_payload(token, expected_type="access")
|
||||
|
||||
if not payload:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
try:
|
||||
user_id = int(payload.get("sub"))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token payload",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
if user.status != "active":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User account is not active",
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_optional_cabinet_user(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> Optional[User]:
|
||||
"""
|
||||
Optionally get current authenticated cabinet user.
|
||||
|
||||
Returns None if no valid token is provided instead of raising an exception.
|
||||
"""
|
||||
if not credentials:
|
||||
return None
|
||||
|
||||
token = credentials.credentials
|
||||
payload = get_token_payload(token, expected_type="access")
|
||||
|
||||
if not payload:
|
||||
return None
|
||||
|
||||
try:
|
||||
user_id = int(payload.get("sub"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
|
||||
if not user or user.status != "active":
|
||||
return None
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_admin_user(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
) -> User:
|
||||
"""
|
||||
Get current authenticated admin user.
|
||||
|
||||
Checks if the user's telegram_id is in ADMIN_IDS from settings.
|
||||
|
||||
Args:
|
||||
user: Authenticated User object
|
||||
|
||||
Returns:
|
||||
Authenticated admin User object
|
||||
|
||||
Raises:
|
||||
HTTPException: If user is not an admin
|
||||
"""
|
||||
if not settings.is_admin(user.telegram_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin access required",
|
||||
)
|
||||
|
||||
return user
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Cabinet API routes."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .auth import router as auth_router
|
||||
from .subscription import router as subscription_router
|
||||
from .balance import router as balance_router
|
||||
from .referral import router as referral_router
|
||||
from .tickets import router as tickets_router
|
||||
from .admin_tickets import router as admin_tickets_router
|
||||
from .admin_settings import router as admin_settings_router
|
||||
from .admin_apps import router as admin_apps_router
|
||||
from .promocode import router as promocode_router
|
||||
from .contests import router as contests_router
|
||||
from .polls import router as polls_router
|
||||
from .promo import router as promo_router
|
||||
from .notifications import router as notifications_router
|
||||
from .info import router as info_router
|
||||
from .branding import router as branding_router
|
||||
|
||||
# Main cabinet router
|
||||
router = APIRouter(prefix="/cabinet", tags=["Cabinet"])
|
||||
|
||||
# Include all sub-routers
|
||||
router.include_router(auth_router)
|
||||
router.include_router(subscription_router)
|
||||
router.include_router(balance_router)
|
||||
router.include_router(referral_router)
|
||||
router.include_router(tickets_router)
|
||||
router.include_router(promocode_router)
|
||||
router.include_router(contests_router)
|
||||
router.include_router(polls_router)
|
||||
router.include_router(promo_router)
|
||||
router.include_router(notifications_router)
|
||||
router.include_router(info_router)
|
||||
router.include_router(branding_router)
|
||||
|
||||
# Admin routes
|
||||
router.include_router(admin_tickets_router)
|
||||
router.include_router(admin_settings_router)
|
||||
router.include_router(admin_apps_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,421 @@
|
||||
"""Admin routes for managing VPN applications in app-config.json."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import List, Optional, Dict, Any
|
||||
from pathlib import Path
|
||||
|
||||
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.config import settings
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/apps", tags=["Cabinet Admin Apps"])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
class LocalizedText(BaseModel):
|
||||
"""Localized text for multiple languages."""
|
||||
en: str = ""
|
||||
ru: str = ""
|
||||
zh: Optional[str] = ""
|
||||
fa: Optional[str] = ""
|
||||
|
||||
|
||||
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: Optional[List[AppButton]] = None
|
||||
title: Optional[LocalizedText] = None
|
||||
|
||||
|
||||
class AppDefinition(BaseModel):
|
||||
"""VPN application definition."""
|
||||
id: str
|
||||
name: str
|
||||
isFeatured: bool = False
|
||||
urlScheme: str
|
||||
isNeedBase64Encoding: Optional[bool] = None
|
||||
installationStep: AppStep
|
||||
addSubscriptionStep: AppStep
|
||||
connectAndUseStep: AppStep
|
||||
additionalBeforeAddSubscriptionStep: Optional[AppStep] = None
|
||||
additionalAfterAddSubscriptionStep: Optional[AppStep] = 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, "r", 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(f"Admin {admin.id} created app '{request.app.id}' for 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(f"Admin {admin.id} updated app '{app_id}' in 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(f"Admin {admin.id} deleted app '{app_id}' from 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(f"Admin {admin.id} reordered apps in 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(f"Admin {admin.id} updated branding")
|
||||
|
||||
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=f"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(f"Admin {admin.id} copied app '{app_id}' from '{platform}' to '{target_platform}' as '{new_id}'")
|
||||
|
||||
return {"status": "copied", "new_id": new_id, "target_platform": target_platform}
|
||||
@@ -0,0 +1,264 @@
|
||||
"""Admin settings routes for cabinet - system configuration management."""
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.services.system_settings_service import (
|
||||
ReadOnlySettingError,
|
||||
bot_configuration_service,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/settings", tags=["Admin Settings"])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
class SettingCategoryRef(BaseModel):
|
||||
"""Reference to category."""
|
||||
key: str
|
||||
label: str
|
||||
|
||||
|
||||
class SettingCategorySummary(BaseModel):
|
||||
"""Category summary."""
|
||||
key: str
|
||||
label: str
|
||||
description: str = ""
|
||||
items: int
|
||||
|
||||
|
||||
class SettingChoice(BaseModel):
|
||||
"""Choice option for setting."""
|
||||
value: Any
|
||||
label: str
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class SettingHint(BaseModel):
|
||||
"""Setting hints and guidance."""
|
||||
description: str = ""
|
||||
format: str = ""
|
||||
example: str = ""
|
||||
warning: str = ""
|
||||
|
||||
|
||||
class SettingDefinition(BaseModel):
|
||||
"""Full setting definition with current state."""
|
||||
key: str
|
||||
name: str
|
||||
category: SettingCategoryRef
|
||||
type: str
|
||||
is_optional: bool
|
||||
current: Any = Field(default=None)
|
||||
original: Any = Field(default=None)
|
||||
has_override: bool
|
||||
read_only: bool = Field(default=False)
|
||||
choices: List[SettingChoice] = Field(default_factory=list)
|
||||
hint: Optional[SettingHint] = None
|
||||
|
||||
|
||||
class SettingUpdateRequest(BaseModel):
|
||||
"""Request to update setting value."""
|
||||
value: Any
|
||||
|
||||
|
||||
# ============ Helper Functions ============
|
||||
|
||||
def _coerce_value(key: str, value: Any) -> Any:
|
||||
"""Convert and validate value for a setting."""
|
||||
definition = bot_configuration_service.get_definition(key)
|
||||
|
||||
if value is None:
|
||||
if definition.is_optional:
|
||||
return None
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Value is required")
|
||||
|
||||
python_type = definition.python_type
|
||||
|
||||
try:
|
||||
if python_type is bool:
|
||||
if isinstance(value, bool):
|
||||
normalized = value
|
||||
elif isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
if lowered in {"true", "1", "yes", "on", "да"}:
|
||||
normalized = True
|
||||
elif lowered in {"false", "0", "no", "off", "нет"}:
|
||||
normalized = False
|
||||
else:
|
||||
raise ValueError("invalid bool")
|
||||
else:
|
||||
raise ValueError("invalid bool")
|
||||
|
||||
elif python_type is int:
|
||||
normalized = int(value)
|
||||
elif python_type is float:
|
||||
normalized = float(value)
|
||||
else:
|
||||
normalized = str(value)
|
||||
except ValueError:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid value type") from None
|
||||
|
||||
choices = bot_configuration_service.get_choice_options(key)
|
||||
if choices:
|
||||
allowed_values = {option.value for option in choices}
|
||||
if normalized not in allowed_values:
|
||||
readable = ", ".join(bot_configuration_service.format_value(opt.value) for opt in choices)
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Value must be one of: {readable}",
|
||||
)
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _serialize_definition(definition, include_choices: bool = True) -> SettingDefinition:
|
||||
"""Serialize setting definition to response model."""
|
||||
current = bot_configuration_service.get_current_value(definition.key)
|
||||
original = bot_configuration_service.get_original_value(definition.key)
|
||||
has_override = bot_configuration_service.has_override(definition.key)
|
||||
|
||||
choices: List[SettingChoice] = []
|
||||
if include_choices:
|
||||
choices = [
|
||||
SettingChoice(
|
||||
value=option.value,
|
||||
label=option.label,
|
||||
description=option.description,
|
||||
)
|
||||
for option in bot_configuration_service.get_choice_options(definition.key)
|
||||
]
|
||||
|
||||
# Get setting hints
|
||||
guidance = bot_configuration_service.get_setting_guidance(definition.key)
|
||||
hint = SettingHint(
|
||||
description=guidance.get("description", ""),
|
||||
format=guidance.get("format", ""),
|
||||
example=guidance.get("example", ""),
|
||||
warning=guidance.get("warning", ""),
|
||||
)
|
||||
|
||||
return SettingDefinition(
|
||||
key=definition.key,
|
||||
name=definition.display_name,
|
||||
category=SettingCategoryRef(
|
||||
key=definition.category_key,
|
||||
label=definition.category_label,
|
||||
),
|
||||
type=definition.type_label,
|
||||
is_optional=definition.is_optional,
|
||||
current=current,
|
||||
original=original,
|
||||
has_override=has_override,
|
||||
read_only=bot_configuration_service.is_read_only(definition.key),
|
||||
choices=choices,
|
||||
hint=hint,
|
||||
)
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
@router.get("/categories", response_model=List[SettingCategorySummary])
|
||||
async def list_categories(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get list of setting categories."""
|
||||
categories = bot_configuration_service.get_categories()
|
||||
return [
|
||||
SettingCategorySummary(
|
||||
key=key,
|
||||
label=label,
|
||||
description=bot_configuration_service.get_category_description(key),
|
||||
items=count,
|
||||
)
|
||||
for key, label, count in categories
|
||||
]
|
||||
|
||||
|
||||
@router.get("", response_model=List[SettingDefinition])
|
||||
async def list_settings(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
category: Optional[str] = Query(default=None, alias="category_key"),
|
||||
):
|
||||
"""Get list of all settings or settings for a specific category."""
|
||||
items: List[SettingDefinition] = []
|
||||
|
||||
if category:
|
||||
definitions = bot_configuration_service.get_settings_for_category(category)
|
||||
items.extend(_serialize_definition(defn) for defn in definitions)
|
||||
return items
|
||||
|
||||
for category_key, _, _ in bot_configuration_service.get_categories():
|
||||
definitions = bot_configuration_service.get_settings_for_category(category_key)
|
||||
items.extend(_serialize_definition(defn) for defn in definitions)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
@router.get("/{key}", response_model=SettingDefinition)
|
||||
async def get_setting(
|
||||
key: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get a specific setting by key."""
|
||||
try:
|
||||
definition = bot_configuration_service.get_definition(key)
|
||||
except KeyError as error:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error
|
||||
|
||||
return _serialize_definition(definition)
|
||||
|
||||
|
||||
@router.put("/{key}", response_model=SettingDefinition)
|
||||
async def update_setting(
|
||||
key: str,
|
||||
payload: SettingUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update a setting value."""
|
||||
try:
|
||||
definition = bot_configuration_service.get_definition(key)
|
||||
except KeyError as error:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error
|
||||
|
||||
value = _coerce_value(key, payload.value)
|
||||
try:
|
||||
await bot_configuration_service.set_value(db, key, value)
|
||||
except ReadOnlySettingError as error:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Admin {admin.telegram_id} updated setting {key} to {value}")
|
||||
return _serialize_definition(definition)
|
||||
|
||||
|
||||
@router.delete("/{key}", response_model=SettingDefinition)
|
||||
async def reset_setting(
|
||||
key: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reset a setting to its default value."""
|
||||
try:
|
||||
definition = bot_configuration_service.get_definition(key)
|
||||
except KeyError as error:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error
|
||||
|
||||
try:
|
||||
await bot_configuration_service.reset_value(db, key)
|
||||
except ReadOnlySettingError as error:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Admin {admin.telegram_id} reset setting {key}")
|
||||
return _serialize_definition(definition)
|
||||
@@ -0,0 +1,450 @@
|
||||
"""Admin tickets routes for cabinet."""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc
|
||||
from sqlalchemy.orm import selectinload
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.database.models import User, Ticket, TicketMessage
|
||||
from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD
|
||||
from app.config import settings
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.tickets import TicketMessageResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/tickets", tags=["Cabinet Admin Tickets"])
|
||||
|
||||
|
||||
# Admin-specific schemas
|
||||
class AdminTicketUserInfo(BaseModel):
|
||||
"""User info for admin view."""
|
||||
id: int
|
||||
telegram_id: int
|
||||
username: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AdminTicketResponse(BaseModel):
|
||||
"""Ticket data for admin."""
|
||||
id: int
|
||||
title: str
|
||||
status: str
|
||||
priority: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
closed_at: Optional[datetime] = None
|
||||
messages_count: int = 0
|
||||
user: Optional[AdminTicketUserInfo] = None
|
||||
last_message: Optional[TicketMessageResponse] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AdminTicketDetailResponse(BaseModel):
|
||||
"""Ticket with all messages for admin."""
|
||||
id: int
|
||||
title: str
|
||||
status: str
|
||||
priority: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
closed_at: Optional[datetime] = None
|
||||
is_reply_blocked: bool = False
|
||||
user: Optional[AdminTicketUserInfo] = None
|
||||
messages: List[TicketMessageResponse] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AdminTicketListResponse(BaseModel):
|
||||
"""Paginated ticket list for admin."""
|
||||
items: List[AdminTicketResponse]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
pages: int
|
||||
|
||||
|
||||
class AdminReplyRequest(BaseModel):
|
||||
"""Admin reply to ticket."""
|
||||
message: str = Field(..., min_length=1, max_length=4000, description="Reply message")
|
||||
|
||||
|
||||
class AdminStatusUpdateRequest(BaseModel):
|
||||
"""Update ticket status."""
|
||||
status: str = Field(..., description="New status: open, answered, pending, closed")
|
||||
|
||||
|
||||
class AdminPriorityUpdateRequest(BaseModel):
|
||||
"""Update ticket priority."""
|
||||
priority: str = Field(..., description="New priority: low, normal, high, urgent")
|
||||
|
||||
|
||||
class AdminStatsResponse(BaseModel):
|
||||
"""Ticket statistics for admin."""
|
||||
total: int
|
||||
open: int
|
||||
pending: int
|
||||
answered: int
|
||||
closed: int
|
||||
|
||||
|
||||
def _message_to_response(message: TicketMessage) -> TicketMessageResponse:
|
||||
"""Convert TicketMessage to response."""
|
||||
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),
|
||||
media_type=message.media_type,
|
||||
media_caption=message.media_caption,
|
||||
created_at=message.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _user_to_info(user: User) -> AdminTicketUserInfo:
|
||||
"""Convert User to admin info."""
|
||||
return AdminTicketUserInfo(
|
||||
id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
)
|
||||
|
||||
|
||||
def _ticket_to_admin_response(ticket: Ticket, include_messages: bool = False) -> AdminTicketResponse:
|
||||
"""Convert Ticket to admin response."""
|
||||
last_message = None
|
||||
messages_count = len(ticket.messages) if ticket.messages else 0
|
||||
|
||||
if ticket.messages:
|
||||
last_msg = max(ticket.messages, key=lambda m: m.created_at)
|
||||
last_message = _message_to_response(last_msg)
|
||||
|
||||
user_info = None
|
||||
if hasattr(ticket, 'user') and ticket.user:
|
||||
user_info = _user_to_info(ticket.user)
|
||||
|
||||
return AdminTicketResponse(
|
||||
id=ticket.id,
|
||||
title=ticket.title or f"Ticket #{ticket.id}",
|
||||
status=ticket.status,
|
||||
priority=ticket.priority or "normal",
|
||||
created_at=ticket.created_at,
|
||||
updated_at=ticket.updated_at or ticket.created_at,
|
||||
closed_at=ticket.closed_at,
|
||||
messages_count=messages_count,
|
||||
user=user_info,
|
||||
last_message=last_message,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats", response_model=AdminStatsResponse)
|
||||
async def get_ticket_stats(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get ticket statistics."""
|
||||
# Total count
|
||||
total_result = await db.execute(select(func.count()).select_from(Ticket))
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Count by status
|
||||
statuses = {}
|
||||
for status_name in ["open", "pending", "answered", "closed"]:
|
||||
result = await db.execute(
|
||||
select(func.count()).select_from(Ticket).where(Ticket.status == status_name)
|
||||
)
|
||||
statuses[status_name] = result.scalar() or 0
|
||||
|
||||
return AdminStatsResponse(
|
||||
total=total,
|
||||
open=statuses.get("open", 0),
|
||||
pending=statuses.get("pending", 0),
|
||||
answered=statuses.get("answered", 0),
|
||||
closed=statuses.get("closed", 0),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=AdminTicketListResponse)
|
||||
async def get_all_tickets(
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
per_page: int = Query(20, ge=1, le=100, description="Items per page"),
|
||||
status_filter: Optional[str] = Query(None, alias="status", description="Filter by status"),
|
||||
priority_filter: Optional[str] = Query(None, alias="priority", description="Filter by priority"),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get all tickets for admin."""
|
||||
# Base query with user relationship
|
||||
query = (
|
||||
select(Ticket)
|
||||
.options(selectinload(Ticket.messages), selectinload(Ticket.user))
|
||||
)
|
||||
|
||||
# Build count query
|
||||
count_query = select(func.count()).select_from(Ticket)
|
||||
|
||||
# Apply filters
|
||||
if status_filter:
|
||||
query = query.where(Ticket.status == status_filter)
|
||||
count_query = count_query.where(Ticket.status == status_filter)
|
||||
|
||||
if priority_filter:
|
||||
query = query.where(Ticket.priority == priority_filter)
|
||||
count_query = count_query.where(Ticket.priority == priority_filter)
|
||||
|
||||
# Get total count
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Paginate - order by updated_at desc (newest first)
|
||||
offset = (page - 1) * per_page
|
||||
query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(per_page)
|
||||
|
||||
result = await db.execute(query)
|
||||
tickets = result.scalars().all()
|
||||
|
||||
items = [_ticket_to_admin_response(t) for t in tickets]
|
||||
pages = math.ceil(total / per_page) if total > 0 else 1
|
||||
|
||||
return AdminTicketListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{ticket_id}", response_model=AdminTicketDetailResponse)
|
||||
async def get_ticket_detail(
|
||||
ticket_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get ticket with all messages for admin."""
|
||||
query = (
|
||||
select(Ticket)
|
||||
.where(Ticket.id == ticket_id)
|
||||
.options(selectinload(Ticket.messages), selectinload(Ticket.user))
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
ticket = result.scalar_one_or_none()
|
||||
|
||||
if not ticket:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Ticket not found",
|
||||
)
|
||||
|
||||
messages = sorted(ticket.messages or [], key=lambda m: m.created_at)
|
||||
messages_response = [_message_to_response(m) for m in messages]
|
||||
|
||||
user_info = None
|
||||
if ticket.user:
|
||||
user_info = _user_to_info(ticket.user)
|
||||
|
||||
return AdminTicketDetailResponse(
|
||||
id=ticket.id,
|
||||
title=ticket.title or f"Ticket #{ticket.id}",
|
||||
status=ticket.status,
|
||||
priority=ticket.priority or "normal",
|
||||
created_at=ticket.created_at,
|
||||
updated_at=ticket.updated_at or ticket.created_at,
|
||||
closed_at=ticket.closed_at,
|
||||
is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, "is_reply_blocked") else False,
|
||||
user=user_info,
|
||||
messages=messages_response,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/reply", response_model=TicketMessageResponse)
|
||||
async def reply_to_ticket(
|
||||
ticket_id: int,
|
||||
request: AdminReplyRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reply to a ticket as admin."""
|
||||
# Get ticket
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False, load_user=True)
|
||||
|
||||
if not ticket:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Ticket not found",
|
||||
)
|
||||
|
||||
# Create admin message
|
||||
message = TicketMessage(
|
||||
ticket_id=ticket.id,
|
||||
user_id=ticket.user_id,
|
||||
message_text=request.message,
|
||||
is_from_admin=True,
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(message)
|
||||
|
||||
# Update ticket status to answered
|
||||
ticket.status = "answered"
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
|
||||
# Try to notify user via Telegram
|
||||
try:
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
try:
|
||||
from app.handlers.admin.tickets import notify_user_about_ticket_reply
|
||||
await notify_user_about_ticket_reply(bot, ticket, request.message, db)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to notify user about ticket reply: {e}")
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to send Telegram notification: {e}")
|
||||
|
||||
return _message_to_response(message)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/status", response_model=AdminTicketDetailResponse)
|
||||
async def update_ticket_status(
|
||||
ticket_id: int,
|
||||
request: AdminStatusUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update ticket status."""
|
||||
allowed_statuses = {"open", "pending", "answered", "closed"}
|
||||
if request.status not in allowed_statuses:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid status. Allowed: {', '.join(allowed_statuses)}",
|
||||
)
|
||||
|
||||
query = (
|
||||
select(Ticket)
|
||||
.where(Ticket.id == ticket_id)
|
||||
.options(selectinload(Ticket.messages), selectinload(Ticket.user))
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
ticket = result.scalar_one_or_none()
|
||||
|
||||
if not ticket:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Ticket not found",
|
||||
)
|
||||
|
||||
ticket.status = request.status
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
if request.status == "closed":
|
||||
ticket.closed_at = datetime.utcnow()
|
||||
else:
|
||||
ticket.closed_at = None
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(ticket)
|
||||
|
||||
messages = sorted(ticket.messages or [], key=lambda m: m.created_at)
|
||||
messages_response = [_message_to_response(m) for m in messages]
|
||||
|
||||
user_info = None
|
||||
if ticket.user:
|
||||
user_info = _user_to_info(ticket.user)
|
||||
|
||||
return AdminTicketDetailResponse(
|
||||
id=ticket.id,
|
||||
title=ticket.title or f"Ticket #{ticket.id}",
|
||||
status=ticket.status,
|
||||
priority=ticket.priority or "normal",
|
||||
created_at=ticket.created_at,
|
||||
updated_at=ticket.updated_at or ticket.created_at,
|
||||
closed_at=ticket.closed_at,
|
||||
is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, "is_reply_blocked") else False,
|
||||
user=user_info,
|
||||
messages=messages_response,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/priority", response_model=AdminTicketDetailResponse)
|
||||
async def update_ticket_priority(
|
||||
ticket_id: int,
|
||||
request: AdminPriorityUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update ticket priority."""
|
||||
allowed_priorities = {"low", "normal", "high", "urgent"}
|
||||
if request.priority not in allowed_priorities:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid priority. Allowed: {', '.join(allowed_priorities)}",
|
||||
)
|
||||
|
||||
query = (
|
||||
select(Ticket)
|
||||
.where(Ticket.id == ticket_id)
|
||||
.options(selectinload(Ticket.messages), selectinload(Ticket.user))
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
ticket = result.scalar_one_or_none()
|
||||
|
||||
if not ticket:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Ticket not found",
|
||||
)
|
||||
|
||||
ticket.priority = request.priority
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(ticket)
|
||||
|
||||
messages = sorted(ticket.messages or [], key=lambda m: m.created_at)
|
||||
messages_response = [_message_to_response(m) for m in messages]
|
||||
|
||||
user_info = None
|
||||
if ticket.user:
|
||||
user_info = _user_to_info(ticket.user)
|
||||
|
||||
return AdminTicketDetailResponse(
|
||||
id=ticket.id,
|
||||
title=ticket.title or f"Ticket #{ticket.id}",
|
||||
status=ticket.status,
|
||||
priority=ticket.priority or "normal",
|
||||
created_at=ticket.created_at,
|
||||
updated_at=ticket.updated_at or ticket.created_at,
|
||||
closed_at=ticket.closed_at,
|
||||
is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, "is_reply_blocked") else False,
|
||||
user=user_info,
|
||||
messages=messages_response,
|
||||
)
|
||||
@@ -0,0 +1,597 @@
|
||||
"""Authentication routes for cabinet."""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.models import User, CabinetRefreshToken
|
||||
from app.database.crud.user import get_user_by_telegram_id, get_user_by_id, create_user
|
||||
from app.config import settings
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.auth import (
|
||||
TelegramAuthRequest,
|
||||
TelegramWidgetAuthRequest,
|
||||
EmailRegisterRequest,
|
||||
EmailVerifyRequest,
|
||||
EmailLoginRequest,
|
||||
RefreshTokenRequest,
|
||||
PasswordForgotRequest,
|
||||
PasswordResetRequest,
|
||||
TokenResponse,
|
||||
UserResponse,
|
||||
AuthResponse,
|
||||
)
|
||||
from ..auth import (
|
||||
validate_telegram_login_widget,
|
||||
validate_telegram_init_data,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
get_token_payload,
|
||||
hash_password,
|
||||
verify_password,
|
||||
)
|
||||
from ..auth.jwt_handler import get_refresh_token_expires_at
|
||||
from ..auth.email_verification import (
|
||||
generate_verification_token,
|
||||
generate_password_reset_token,
|
||||
get_verification_expires_at,
|
||||
get_password_reset_expires_at,
|
||||
is_token_expired,
|
||||
)
|
||||
from ..services.email_service import email_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["Cabinet Auth"])
|
||||
|
||||
|
||||
def _user_to_response(user: User) -> UserResponse:
|
||||
"""Convert User model to UserResponse."""
|
||||
return UserResponse(
|
||||
id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
email=user.email,
|
||||
email_verified=user.email_verified,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
balance_rubles=user.balance_rubles,
|
||||
referral_code=user.referral_code,
|
||||
language=user.language,
|
||||
created_at=user.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _create_auth_response(user: User) -> AuthResponse:
|
||||
"""Create full auth response with tokens."""
|
||||
access_token = create_access_token(user.id, user.telegram_id)
|
||||
refresh_token = create_refresh_token(user.id)
|
||||
expires_in = settings.get_cabinet_access_token_expire_minutes() * 60
|
||||
|
||||
return AuthResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
token_type="bearer",
|
||||
expires_in=expires_in,
|
||||
user=_user_to_response(user),
|
||||
)
|
||||
|
||||
|
||||
async def _store_refresh_token(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
refresh_token: str,
|
||||
device_info: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Store refresh token hash in database."""
|
||||
token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
|
||||
expires_at = get_refresh_token_expires_at()
|
||||
|
||||
token_record = CabinetRefreshToken(
|
||||
user_id=user_id,
|
||||
token_hash=token_hash,
|
||||
device_info=device_info,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db.add(token_record)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/telegram", response_model=AuthResponse)
|
||||
async def auth_telegram(
|
||||
request: TelegramAuthRequest,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Authenticate using Telegram WebApp initData.
|
||||
|
||||
This endpoint validates the initData from Telegram WebApp and returns
|
||||
JWT tokens for authenticated access.
|
||||
"""
|
||||
user_data = validate_telegram_init_data(request.init_data)
|
||||
|
||||
if not user_data:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired Telegram authentication data",
|
||||
)
|
||||
|
||||
telegram_id = user_data.get("id")
|
||||
if not telegram_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing Telegram user ID",
|
||||
)
|
||||
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
|
||||
# Get user data from initData
|
||||
tg_username = user_data.get("username")
|
||||
tg_first_name = user_data.get("first_name")
|
||||
tg_last_name = user_data.get("last_name")
|
||||
tg_language = user_data.get("language_code", "ru")
|
||||
|
||||
if not user:
|
||||
# Create new user from Telegram initData
|
||||
logger.info(f"Creating new user from cabinet (initData): telegram_id={telegram_id}")
|
||||
user = await create_user(
|
||||
db=db,
|
||||
telegram_id=telegram_id,
|
||||
username=tg_username,
|
||||
first_name=tg_first_name,
|
||||
last_name=tg_last_name,
|
||||
language=tg_language,
|
||||
)
|
||||
logger.info(f"User created successfully: id={user.id}, telegram_id={user.telegram_id}")
|
||||
else:
|
||||
# Update user info from initData (like bot middleware does)
|
||||
updated = False
|
||||
if tg_username and tg_username != user.username:
|
||||
user.username = tg_username
|
||||
updated = True
|
||||
if tg_first_name and tg_first_name != user.first_name:
|
||||
user.first_name = tg_first_name
|
||||
updated = True
|
||||
if tg_last_name and tg_last_name != user.last_name:
|
||||
user.last_name = tg_last_name
|
||||
updated = True
|
||||
if updated:
|
||||
logger.info(f"User {user.id} profile updated from initData")
|
||||
|
||||
if user.status != "active":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User account is not active",
|
||||
)
|
||||
|
||||
# Update last login
|
||||
user.cabinet_last_login = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
response = _create_auth_response(user)
|
||||
|
||||
# Store refresh token
|
||||
await _store_refresh_token(db, user.id, response.refresh_token)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/telegram/widget", response_model=AuthResponse)
|
||||
async def auth_telegram_widget(
|
||||
request: TelegramWidgetAuthRequest,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Authenticate using Telegram Login Widget data.
|
||||
|
||||
This endpoint validates data from Telegram Login Widget and returns
|
||||
JWT tokens for authenticated access.
|
||||
"""
|
||||
widget_data = request.model_dump()
|
||||
|
||||
if not validate_telegram_login_widget(widget_data):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired Telegram authentication data",
|
||||
)
|
||||
|
||||
user = await get_user_by_telegram_id(db, request.id)
|
||||
|
||||
if not user:
|
||||
# Create new user from Telegram data
|
||||
logger.info(f"Creating new user from cabinet: telegram_id={request.id}, username={request.username}")
|
||||
user = await create_user(
|
||||
db=db,
|
||||
telegram_id=request.id,
|
||||
username=request.username,
|
||||
first_name=request.first_name,
|
||||
last_name=request.last_name,
|
||||
language="ru",
|
||||
)
|
||||
logger.info(f"User created successfully: id={user.id}, telegram_id={user.telegram_id}")
|
||||
|
||||
if user.status != "active":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User account is not active",
|
||||
)
|
||||
|
||||
# Update user info from widget data
|
||||
if request.username and request.username != user.username:
|
||||
user.username = request.username
|
||||
if request.first_name and request.first_name != user.first_name:
|
||||
user.first_name = request.first_name
|
||||
if request.last_name != user.last_name:
|
||||
user.last_name = request.last_name
|
||||
|
||||
user.cabinet_last_login = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
response = _create_auth_response(user)
|
||||
await _store_refresh_token(db, user.id, response.refresh_token)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/email/register")
|
||||
async def register_email(
|
||||
request: EmailRegisterRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Register/link email to existing Telegram account.
|
||||
|
||||
Requires valid JWT token from Telegram authentication.
|
||||
Sends verification email to the provided address.
|
||||
"""
|
||||
# Check if email already exists
|
||||
existing_user = await db.execute(
|
||||
select(User).where(User.email == request.email)
|
||||
)
|
||||
if existing_user.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="This email is already registered",
|
||||
)
|
||||
|
||||
# Check if user already has email
|
||||
if user.email and user.email_verified:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="You already have a verified email",
|
||||
)
|
||||
|
||||
# Generate verification token
|
||||
verification_token = generate_verification_token()
|
||||
verification_expires = get_verification_expires_at()
|
||||
|
||||
# Update user
|
||||
user.email = request.email
|
||||
user.email_verified = False
|
||||
user.password_hash = hash_password(request.password)
|
||||
user.email_verification_token = verification_token
|
||||
user.email_verification_expires = verification_expires
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Send verification email
|
||||
if email_service.is_configured():
|
||||
# TODO: Get actual verification URL from settings
|
||||
verification_url = "https://example.com/cabinet/verify-email"
|
||||
email_service.send_verification_email(
|
||||
to_email=request.email,
|
||||
verification_token=verification_token,
|
||||
verification_url=verification_url,
|
||||
username=user.first_name,
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Verification email sent",
|
||||
"email": request.email,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/email/verify")
|
||||
async def verify_email(
|
||||
request: EmailVerifyRequest,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Verify email with token."""
|
||||
# Find user with this token
|
||||
result = await db.execute(
|
||||
select(User).where(User.email_verification_token == request.token)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid verification token",
|
||||
)
|
||||
|
||||
if is_token_expired(user.email_verification_expires):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Verification token has expired",
|
||||
)
|
||||
|
||||
# Mark email as verified
|
||||
user.email_verified = True
|
||||
user.email_verified_at = datetime.utcnow()
|
||||
user.email_verification_token = None
|
||||
user.email_verification_expires = None
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Email verified successfully"}
|
||||
|
||||
|
||||
@router.post("/email/resend")
|
||||
async def resend_verification(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Resend verification email."""
|
||||
if not user.email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No email address to verify",
|
||||
)
|
||||
|
||||
if user.email_verified:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email is already verified",
|
||||
)
|
||||
|
||||
# Generate new token
|
||||
verification_token = generate_verification_token()
|
||||
verification_expires = get_verification_expires_at()
|
||||
|
||||
user.email_verification_token = verification_token
|
||||
user.email_verification_expires = verification_expires
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Send verification email
|
||||
if email_service.is_configured():
|
||||
verification_url = "https://example.com/cabinet/verify-email"
|
||||
email_service.send_verification_email(
|
||||
to_email=user.email,
|
||||
verification_token=verification_token,
|
||||
verification_url=verification_url,
|
||||
username=user.first_name,
|
||||
)
|
||||
|
||||
return {"message": "Verification email sent"}
|
||||
|
||||
|
||||
@router.post("/email/login", response_model=AuthResponse)
|
||||
async def login_email(
|
||||
request: EmailLoginRequest,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Login with email and password."""
|
||||
# Find user by email
|
||||
result = await db.execute(
|
||||
select(User).where(User.email == request.email)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid email or password",
|
||||
)
|
||||
|
||||
if not user.password_hash:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Password login not configured for this account",
|
||||
)
|
||||
|
||||
if not verify_password(request.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid email or password",
|
||||
)
|
||||
|
||||
if not user.email_verified:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Please verify your email first",
|
||||
)
|
||||
|
||||
if user.status != "active":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User account is not active",
|
||||
)
|
||||
|
||||
user.cabinet_last_login = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
response = _create_auth_response(user)
|
||||
await _store_refresh_token(db, user.id, response.refresh_token)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
async def refresh_token(
|
||||
request: RefreshTokenRequest,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Refresh access token using refresh token."""
|
||||
payload = get_token_payload(request.refresh_token, expected_type="refresh")
|
||||
|
||||
if not payload:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired refresh token",
|
||||
)
|
||||
|
||||
try:
|
||||
user_id = int(payload.get("sub"))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token payload",
|
||||
)
|
||||
|
||||
# Verify token exists in database and is not revoked
|
||||
token_hash = hashlib.sha256(request.refresh_token.encode()).hexdigest()
|
||||
result = await db.execute(
|
||||
select(CabinetRefreshToken).where(
|
||||
CabinetRefreshToken.token_hash == token_hash,
|
||||
CabinetRefreshToken.revoked_at.is_(None),
|
||||
)
|
||||
)
|
||||
token_record = result.scalar_one_or_none()
|
||||
|
||||
if not token_record:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Refresh token not found or revoked",
|
||||
)
|
||||
|
||||
if not token_record.is_valid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Refresh token is no longer valid",
|
||||
)
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
|
||||
if not user or user.status != "active":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found or inactive",
|
||||
)
|
||||
|
||||
access_token = create_access_token(user.id, user.telegram_id)
|
||||
expires_in = settings.get_cabinet_access_token_expire_minutes() * 60
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=request.refresh_token,
|
||||
token_type="bearer",
|
||||
expires_in=expires_in,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
request: RefreshTokenRequest,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Logout and revoke refresh token."""
|
||||
token_hash = hashlib.sha256(request.refresh_token.encode()).hexdigest()
|
||||
|
||||
result = await db.execute(
|
||||
select(CabinetRefreshToken).where(
|
||||
CabinetRefreshToken.token_hash == token_hash,
|
||||
)
|
||||
)
|
||||
token_record = result.scalar_one_or_none()
|
||||
|
||||
if token_record:
|
||||
token_record.revoked_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Logged out successfully"}
|
||||
|
||||
|
||||
@router.post("/password/forgot")
|
||||
async def forgot_password(
|
||||
request: PasswordForgotRequest,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Request password reset."""
|
||||
result = await db.execute(
|
||||
select(User).where(User.email == request.email)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
# Always return success to prevent email enumeration
|
||||
if not user or not user.email_verified:
|
||||
return {"message": "If the email exists, a password reset link has been sent"}
|
||||
|
||||
# Generate reset token
|
||||
reset_token = generate_password_reset_token()
|
||||
reset_expires = get_password_reset_expires_at()
|
||||
|
||||
user.password_reset_token = reset_token
|
||||
user.password_reset_expires = reset_expires
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Send reset email
|
||||
if email_service.is_configured():
|
||||
reset_url = "https://example.com/cabinet/reset-password"
|
||||
email_service.send_password_reset_email(
|
||||
to_email=user.email,
|
||||
reset_token=reset_token,
|
||||
reset_url=reset_url,
|
||||
username=user.first_name,
|
||||
)
|
||||
|
||||
return {"message": "If the email exists, a password reset link has been sent"}
|
||||
|
||||
|
||||
@router.post("/password/reset")
|
||||
async def reset_password(
|
||||
request: PasswordResetRequest,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reset password with token."""
|
||||
result = await db.execute(
|
||||
select(User).where(User.password_reset_token == request.token)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid reset token",
|
||||
)
|
||||
|
||||
if is_token_expired(user.password_reset_expires):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Reset token has expired",
|
||||
)
|
||||
|
||||
# Update password
|
||||
user.password_hash = hash_password(request.password)
|
||||
user.password_reset_token = None
|
||||
user.password_reset_expires = None
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Password reset successfully"}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_current_user(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
):
|
||||
"""Get current authenticated user info."""
|
||||
return _user_to_response(user)
|
||||
|
||||
|
||||
@router.get("/me/is-admin")
|
||||
async def check_is_admin(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
):
|
||||
"""Check if current user is an admin."""
|
||||
is_admin = settings.is_admin(user.telegram_id)
|
||||
return {"is_admin": is_admin}
|
||||
@@ -0,0 +1,366 @@
|
||||
"""Balance and payment routes for cabinet."""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc
|
||||
|
||||
from app.database.models import User, Transaction
|
||||
from app.config import settings
|
||||
from app.services.yookassa_service import YooKassaService
|
||||
from app.external.cryptobot import CryptoBotService
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.balance import (
|
||||
BalanceResponse,
|
||||
TransactionResponse,
|
||||
TransactionListResponse,
|
||||
PaymentMethodResponse,
|
||||
TopUpRequest,
|
||||
TopUpResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/balance", tags=["Cabinet Balance"])
|
||||
|
||||
|
||||
@router.get("", response_model=BalanceResponse)
|
||||
async def get_balance(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get current user's balance."""
|
||||
# Reload user from current session to get fresh data
|
||||
# (user object is from different session in get_current_cabinet_user)
|
||||
fresh_user = await get_user_by_id(db, user.id)
|
||||
if not fresh_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
return BalanceResponse(
|
||||
balance_kopeks=fresh_user.balance_kopeks,
|
||||
balance_rubles=fresh_user.balance_kopeks / 100,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/transactions", response_model=TransactionListResponse)
|
||||
async def get_transactions(
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
per_page: int = Query(20, ge=1, le=100, description="Items per page"),
|
||||
type: Optional[str] = Query(None, description="Filter by transaction type"),
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get transaction history."""
|
||||
# Base query
|
||||
query = select(Transaction).where(Transaction.user_id == user.id)
|
||||
|
||||
# Filter by type
|
||||
if type:
|
||||
query = query.where(Transaction.type == type)
|
||||
|
||||
# Get total count
|
||||
count_query = select(func.count()).select_from(Transaction).where(Transaction.user_id == user.id)
|
||||
if type:
|
||||
count_query = count_query.where(Transaction.type == type)
|
||||
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Paginate
|
||||
offset = (page - 1) * per_page
|
||||
query = query.order_by(desc(Transaction.created_at)).offset(offset).limit(per_page)
|
||||
|
||||
result = await db.execute(query)
|
||||
transactions = result.scalars().all()
|
||||
|
||||
items = [
|
||||
TransactionResponse(
|
||||
id=t.id,
|
||||
type=t.type,
|
||||
amount_kopeks=t.amount_kopeks,
|
||||
amount_rubles=t.amount_kopeks / 100,
|
||||
description=t.description,
|
||||
payment_method=t.payment_method,
|
||||
is_completed=t.is_completed,
|
||||
created_at=t.created_at,
|
||||
completed_at=t.completed_at,
|
||||
)
|
||||
for t in transactions
|
||||
]
|
||||
|
||||
pages = math.ceil(total / per_page) if total > 0 else 1
|
||||
|
||||
return TransactionListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/payment-methods", response_model=List[PaymentMethodResponse])
|
||||
async def get_payment_methods():
|
||||
"""Get available payment methods."""
|
||||
methods = []
|
||||
|
||||
# YooKassa
|
||||
if settings.is_yookassa_enabled():
|
||||
methods.append(PaymentMethodResponse(
|
||||
id="yookassa",
|
||||
name="YooKassa (Bank Card)",
|
||||
description="Pay with bank card via YooKassa",
|
||||
min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS,
|
||||
max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS,
|
||||
is_available=True,
|
||||
))
|
||||
|
||||
# CryptoBot
|
||||
if settings.is_cryptobot_enabled():
|
||||
methods.append(PaymentMethodResponse(
|
||||
id="cryptobot",
|
||||
name="CryptoBot",
|
||||
description="Pay with cryptocurrency via CryptoBot",
|
||||
min_amount_kopeks=1000,
|
||||
max_amount_kopeks=10000000,
|
||||
is_available=True,
|
||||
))
|
||||
|
||||
# Telegram Stars
|
||||
if settings.TELEGRAM_STARS_ENABLED:
|
||||
methods.append(PaymentMethodResponse(
|
||||
id="telegram_stars",
|
||||
name="Telegram Stars",
|
||||
description="Pay with Telegram Stars",
|
||||
min_amount_kopeks=100,
|
||||
max_amount_kopeks=1000000,
|
||||
is_available=True,
|
||||
))
|
||||
|
||||
# Heleket
|
||||
if settings.is_heleket_enabled():
|
||||
methods.append(PaymentMethodResponse(
|
||||
id="heleket",
|
||||
name="Heleket Crypto",
|
||||
description="Pay with cryptocurrency via Heleket",
|
||||
min_amount_kopeks=1000,
|
||||
max_amount_kopeks=10000000,
|
||||
is_available=True,
|
||||
))
|
||||
|
||||
# MulenPay
|
||||
if settings.is_mulenpay_enabled():
|
||||
methods.append(PaymentMethodResponse(
|
||||
id="mulenpay",
|
||||
name=settings.get_mulenpay_display_name(),
|
||||
description="MulenPay payment",
|
||||
min_amount_kopeks=settings.MULENPAY_MIN_AMOUNT_KOPEKS,
|
||||
max_amount_kopeks=settings.MULENPAY_MAX_AMOUNT_KOPEKS,
|
||||
is_available=True,
|
||||
))
|
||||
|
||||
# PAL24
|
||||
if settings.is_pal24_enabled():
|
||||
methods.append(PaymentMethodResponse(
|
||||
id="pal24",
|
||||
name="PAL24",
|
||||
description="Pay via PAL24",
|
||||
min_amount_kopeks=settings.PAL24_MIN_AMOUNT_KOPEKS,
|
||||
max_amount_kopeks=settings.PAL24_MAX_AMOUNT_KOPEKS,
|
||||
is_available=True,
|
||||
))
|
||||
|
||||
# Platega
|
||||
if settings.is_platega_enabled():
|
||||
methods.append(PaymentMethodResponse(
|
||||
id="platega",
|
||||
name="Platega",
|
||||
description="Pay via Platega",
|
||||
min_amount_kopeks=settings.PLATEGA_MIN_AMOUNT_KOPEKS,
|
||||
max_amount_kopeks=settings.PLATEGA_MAX_AMOUNT_KOPEKS,
|
||||
is_available=True,
|
||||
))
|
||||
|
||||
# Wata
|
||||
if settings.is_wata_enabled():
|
||||
methods.append(PaymentMethodResponse(
|
||||
id="wata",
|
||||
name="Wata",
|
||||
description="Pay via Wata",
|
||||
min_amount_kopeks=settings.WATA_MIN_AMOUNT_KOPEKS,
|
||||
max_amount_kopeks=settings.WATA_MAX_AMOUNT_KOPEKS,
|
||||
is_available=True,
|
||||
))
|
||||
|
||||
return methods
|
||||
|
||||
|
||||
@router.post("/topup", response_model=TopUpResponse)
|
||||
async def create_topup(
|
||||
request: TopUpRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Create payment for balance top-up."""
|
||||
# Validate payment method
|
||||
methods = await get_payment_methods()
|
||||
method = next((m for m in methods if m.id == request.payment_method), None)
|
||||
|
||||
if not method or not method.is_available:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid or unavailable payment method",
|
||||
)
|
||||
|
||||
# Validate amount
|
||||
if request.amount_kopeks < method.min_amount_kopeks:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Minimum amount is {method.min_amount_kopeks / 100:.2f} RUB",
|
||||
)
|
||||
|
||||
if request.amount_kopeks > method.max_amount_kopeks:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Maximum amount is {method.max_amount_kopeks / 100:.2f} RUB",
|
||||
)
|
||||
|
||||
amount_rubles = request.amount_kopeks / 100
|
||||
payment_url = None
|
||||
payment_id = None
|
||||
|
||||
try:
|
||||
if request.payment_method == "yookassa":
|
||||
yookassa_service = YooKassaService()
|
||||
result = await yookassa_service.create_payment(
|
||||
amount=amount_rubles,
|
||||
currency="RUB",
|
||||
description=f"Пополнение баланса на {amount_rubles:.2f} ₽",
|
||||
metadata={
|
||||
"user_id": str(user.id),
|
||||
"amount_kopeks": str(request.amount_kopeks),
|
||||
"type": "balance_topup",
|
||||
"source": "cabinet",
|
||||
},
|
||||
)
|
||||
if result and not result.get("error"):
|
||||
payment_url = result.get("confirmation_url")
|
||||
payment_id = result.get("id")
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create YooKassa payment",
|
||||
)
|
||||
|
||||
elif request.payment_method == "cryptobot":
|
||||
cryptobot_service = CryptoBotService()
|
||||
# Convert RUB to USDT (approximate)
|
||||
usdt_amount = amount_rubles / 100 # Approximate rate
|
||||
result = await cryptobot_service.create_invoice(
|
||||
amount=usdt_amount,
|
||||
asset="USDT",
|
||||
description=f"Balance top-up {amount_rubles:.2f} RUB",
|
||||
payload=f"cabinet_topup_{user.id}_{request.amount_kopeks}",
|
||||
)
|
||||
if result:
|
||||
payment_url = result.get("pay_url") or result.get("bot_invoice_url")
|
||||
payment_id = str(result.get("invoice_id"))
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create CryptoBot invoice",
|
||||
)
|
||||
|
||||
elif request.payment_method == "telegram_stars":
|
||||
# Telegram Stars payments require bot interaction
|
||||
bot_username = settings.get_bot_username() or "bot"
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Telegram Stars payments are only available through the bot. Please use @{bot_username}",
|
||||
)
|
||||
|
||||
elif request.payment_method == "platega":
|
||||
if not settings.is_platega_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Platega payment method is unavailable",
|
||||
)
|
||||
|
||||
active_methods = settings.get_platega_active_methods()
|
||||
if not active_methods:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No Platega payment methods configured",
|
||||
)
|
||||
|
||||
# Use payment_option if provided, otherwise use first active method
|
||||
method_option = request.payment_option or str(active_methods[0])
|
||||
try:
|
||||
method_code = int(str(method_option).strip())
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid Platega payment option",
|
||||
)
|
||||
|
||||
if method_code not in active_methods:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Selected Platega method is unavailable",
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_platega_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_method_code=method_code,
|
||||
)
|
||||
|
||||
if result and result.get("redirect_url"):
|
||||
payment_url = result.get("redirect_url")
|
||||
payment_id = result.get("transaction_id") or str(result.get("local_payment_id", "pending"))
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create Platega payment",
|
||||
)
|
||||
|
||||
else:
|
||||
# For other payment methods, redirect to bot
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="This payment method is only available through the Telegram bot.",
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Payment creation error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create payment. Please try again later.",
|
||||
)
|
||||
|
||||
if not payment_url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Payment URL not received",
|
||||
)
|
||||
|
||||
return TopUpResponse(
|
||||
payment_id=payment_id or "pending",
|
||||
payment_url=payment_url,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
amount_rubles=amount_rubles,
|
||||
status="pending",
|
||||
expires_at=None,
|
||||
)
|
||||
@@ -0,0 +1,281 @@
|
||||
"""Branding routes for cabinet - logo and project name management."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.models import User, SystemSetting
|
||||
from app.config import settings
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/branding", tags=["Branding"])
|
||||
|
||||
# Directory for storing branding assets
|
||||
BRANDING_DIR = Path("data/branding")
|
||||
LOGO_FILENAME = "logo.png"
|
||||
|
||||
# Settings keys
|
||||
BRANDING_NAME_KEY = "CABINET_BRANDING_NAME"
|
||||
BRANDING_LOGO_KEY = "CABINET_BRANDING_LOGO" # Stores "custom" or "default"
|
||||
|
||||
# Allowed image types
|
||||
ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg", "image/webp", "image/svg+xml"}
|
||||
MAX_FILE_SIZE = 2 * 1024 * 1024 # 2MB
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
class BrandingResponse(BaseModel):
|
||||
"""Current branding settings."""
|
||||
name: str
|
||||
logo_url: Optional[str] = None
|
||||
logo_letter: str
|
||||
has_custom_logo: bool
|
||||
|
||||
|
||||
class BrandingNameUpdate(BaseModel):
|
||||
"""Request to update branding name."""
|
||||
name: str
|
||||
|
||||
|
||||
# ============ Helper Functions ============
|
||||
|
||||
def ensure_branding_dir():
|
||||
"""Ensure branding directory exists."""
|
||||
BRANDING_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
async def get_setting_value(db: AsyncSession, key: str) -> Optional[str]:
|
||||
"""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)
|
||||
)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting:
|
||||
setting.value = value
|
||||
else:
|
||||
setting = SystemSetting(key=key, value=value)
|
||||
db.add(setting)
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
def get_logo_path() -> Path:
|
||||
"""Get the path to the custom logo file."""
|
||||
return BRANDING_DIR / LOGO_FILENAME
|
||||
|
||||
|
||||
def has_custom_logo() -> bool:
|
||||
"""Check if a custom logo exists."""
|
||||
return get_logo_path().exists()
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
@router.get("", response_model=BrandingResponse)
|
||||
async def get_branding(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Get current branding settings.
|
||||
This is a public endpoint - no authentication required.
|
||||
"""
|
||||
# Get name from database or use default from env/settings
|
||||
name = await get_setting_value(db, BRANDING_NAME_KEY)
|
||||
if name is None: # Only use fallback if not set at all (empty string is valid)
|
||||
name = getattr(settings, 'CABINET_BRANDING_NAME', None) or \
|
||||
os.getenv('VITE_APP_NAME', 'Cabinet')
|
||||
|
||||
# Check for custom logo
|
||||
custom_logo = has_custom_logo()
|
||||
|
||||
# Get first letter for logo fallback (use "V" if name is empty)
|
||||
logo_letter = name[0].upper() if name else "V"
|
||||
|
||||
return BrandingResponse(
|
||||
name=name,
|
||||
logo_url="/cabinet/branding/logo" if custom_logo else None,
|
||||
logo_letter=logo_letter,
|
||||
has_custom_logo=custom_logo,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/logo")
|
||||
async def get_logo():
|
||||
"""
|
||||
Get the custom logo image.
|
||||
Returns 404 if no custom logo is set.
|
||||
"""
|
||||
logo_path = get_logo_path()
|
||||
|
||||
if not logo_path.exists():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No custom logo set"
|
||||
)
|
||||
|
||||
# Determine media type from file extension
|
||||
suffix = logo_path.suffix.lower()
|
||||
media_types = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
}
|
||||
media_type = media_types.get(suffix, "image/png")
|
||||
|
||||
return FileResponse(
|
||||
logo_path,
|
||||
media_type=media_type,
|
||||
headers={"Cache-Control": "public, max-age=3600"}
|
||||
)
|
||||
|
||||
|
||||
@router.put("/name", response_model=BrandingResponse)
|
||||
async def update_branding_name(
|
||||
payload: BrandingNameUpdate,
|
||||
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)."""
|
||||
name = payload.name.strip() if payload.name else ""
|
||||
|
||||
if len(name) > 50:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Name too long (max 50 characters)"
|
||||
)
|
||||
|
||||
await set_setting_value(db, BRANDING_NAME_KEY, name)
|
||||
|
||||
logger.info(f"Admin {admin.telegram_id} updated branding name to: {name}")
|
||||
|
||||
# Return updated branding
|
||||
custom_logo = has_custom_logo()
|
||||
logo_letter = name[0].upper() if name else "C"
|
||||
|
||||
return BrandingResponse(
|
||||
name=name,
|
||||
logo_url="/cabinet/branding/logo" if custom_logo else None,
|
||||
logo_letter=logo_letter,
|
||||
has_custom_logo=custom_logo,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logo", response_model=BrandingResponse)
|
||||
async def upload_logo(
|
||||
file: UploadFile = File(...),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Upload a custom logo. Admin only."""
|
||||
# Validate content type
|
||||
if file.content_type not in ALLOWED_CONTENT_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid file type. Allowed: PNG, JPEG, WebP, SVG"
|
||||
)
|
||||
|
||||
# Read file content
|
||||
content = await file.read()
|
||||
|
||||
# Validate file size
|
||||
if len(content) > MAX_FILE_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File too large. Maximum size: {MAX_FILE_SIZE // 1024 // 1024}MB"
|
||||
)
|
||||
|
||||
# Ensure directory exists
|
||||
ensure_branding_dir()
|
||||
|
||||
# Determine file extension from content type
|
||||
ext_map = {
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/jpg": ".jpg",
|
||||
"image/webp": ".webp",
|
||||
"image/svg+xml": ".svg",
|
||||
}
|
||||
extension = ext_map.get(file.content_type, ".png")
|
||||
|
||||
# Remove old logo files with any extension
|
||||
for old_file in BRANDING_DIR.glob("logo.*"):
|
||||
old_file.unlink()
|
||||
|
||||
# Save new logo
|
||||
logo_path = BRANDING_DIR / f"logo{extension}"
|
||||
logo_path.write_bytes(content)
|
||||
|
||||
# Mark that we have a custom logo
|
||||
await set_setting_value(db, BRANDING_LOGO_KEY, "custom")
|
||||
|
||||
logger.info(f"Admin {admin.telegram_id} uploaded new logo: {logo_path}")
|
||||
|
||||
# Get current name for response
|
||||
name = await get_setting_value(db, BRANDING_NAME_KEY)
|
||||
if name is None: # Only use fallback if not set at all (empty string is valid)
|
||||
name = getattr(settings, 'CABINET_BRANDING_NAME', None) or \
|
||||
os.getenv('VITE_APP_NAME', 'Cabinet')
|
||||
|
||||
logo_letter = name[0].upper() if name else "C"
|
||||
|
||||
return BrandingResponse(
|
||||
name=name,
|
||||
logo_url="/cabinet/branding/logo",
|
||||
logo_letter=logo_letter,
|
||||
has_custom_logo=True,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/logo", response_model=BrandingResponse)
|
||||
async def delete_logo(
|
||||
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 BRANDING_DIR.glob("logo.*"):
|
||||
old_file.unlink()
|
||||
|
||||
# Update setting
|
||||
await set_setting_value(db, BRANDING_LOGO_KEY, "default")
|
||||
|
||||
logger.info(f"Admin {admin.telegram_id} deleted custom logo")
|
||||
|
||||
# Get current name for response
|
||||
name = await get_setting_value(db, BRANDING_NAME_KEY)
|
||||
if name is None: # Only use fallback if not set at all (empty string is valid)
|
||||
name = getattr(settings, 'CABINET_BRANDING_NAME', None) or \
|
||||
os.getenv('VITE_APP_NAME', 'Cabinet')
|
||||
|
||||
logo_letter = name[0].upper() if name else "C"
|
||||
|
||||
return BrandingResponse(
|
||||
name=name,
|
||||
logo_url=None,
|
||||
logo_letter=logo_letter,
|
||||
has_custom_logo=False,
|
||||
)
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Contests routes for cabinet - user participation in games/contests."""
|
||||
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional, Dict, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User, SubscriptionStatus
|
||||
from app.database.crud.contest import (
|
||||
get_active_rounds,
|
||||
get_attempt,
|
||||
create_attempt,
|
||||
increment_winner_count,
|
||||
)
|
||||
from app.database.crud.subscription import get_subscription_by_user_id, extend_subscription
|
||||
from app.services.contest_rotation_service import (
|
||||
GAME_QUEST,
|
||||
GAME_LOCKS,
|
||||
GAME_CIPHER,
|
||||
GAME_SERVER,
|
||||
GAME_BLITZ,
|
||||
GAME_EMOJI,
|
||||
GAME_ANAGRAM,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/contests", tags=["Cabinet Contests"])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
class ContestInfo(BaseModel):
|
||||
"""Contest/game info."""
|
||||
id: int
|
||||
slug: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
prize_days: int
|
||||
is_available: bool
|
||||
already_played: bool = False
|
||||
|
||||
|
||||
class ContestGameData(BaseModel):
|
||||
"""Data for playing a contest game."""
|
||||
round_id: int
|
||||
game_type: str
|
||||
game_data: Dict[str, Any]
|
||||
instructions: str
|
||||
|
||||
|
||||
class ContestAnswerRequest(BaseModel):
|
||||
"""Request to submit contest answer."""
|
||||
round_id: int
|
||||
answer: str
|
||||
|
||||
|
||||
class ContestResult(BaseModel):
|
||||
"""Result of contest attempt."""
|
||||
is_winner: bool
|
||||
message: str
|
||||
prize_days: Optional[int] = None
|
||||
|
||||
|
||||
# ============ Helpers ============
|
||||
|
||||
def _user_allowed(subscription) -> bool:
|
||||
"""Check if user is allowed to participate in contests."""
|
||||
if not subscription:
|
||||
return False
|
||||
return subscription.status in {
|
||||
SubscriptionStatus.ACTIVE.value,
|
||||
SubscriptionStatus.TRIAL.value,
|
||||
}
|
||||
|
||||
|
||||
async def _award_prize(db: AsyncSession, user_id: int, prize_days: int) -> str:
|
||||
"""Award prize to winner."""
|
||||
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=prize_days)
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"🎁 Extended subscription for user {user_id} by {prize_days} days (contest prize)")
|
||||
return f"Subscription extended by {prize_days} days"
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
class ContestsCountResponse(BaseModel):
|
||||
"""Count of available contests."""
|
||||
count: int
|
||||
|
||||
|
||||
@router.get("/count", response_model=ContestsCountResponse)
|
||||
async def get_contests_count(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get count of contests available for the user."""
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
if not _user_allowed(subscription):
|
||||
return ContestsCountResponse(count=0)
|
||||
|
||||
active_rounds = await get_active_rounds(db)
|
||||
|
||||
# Count unique available contests (not yet played)
|
||||
count = 0
|
||||
seen_templates = set()
|
||||
for rnd in active_rounds:
|
||||
if not rnd.template or not rnd.template.is_enabled:
|
||||
continue
|
||||
tpl_slug = rnd.template.slug if rnd.template else ""
|
||||
if tpl_slug in seen_templates:
|
||||
continue
|
||||
seen_templates.add(tpl_slug)
|
||||
|
||||
# Check if user already played this round
|
||||
attempt = await get_attempt(db, rnd.id, user.id)
|
||||
if not attempt:
|
||||
count += 1
|
||||
|
||||
return ContestsCountResponse(count=count)
|
||||
|
||||
|
||||
@router.get("", response_model=List[ContestInfo])
|
||||
async def get_contests(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of available contests/games."""
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
if not _user_allowed(subscription):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Contests are only available for users with active or trial subscriptions",
|
||||
)
|
||||
|
||||
active_rounds = await get_active_rounds(db)
|
||||
|
||||
# Group by template to avoid duplicates
|
||||
unique_templates = {}
|
||||
for rnd in active_rounds:
|
||||
if not rnd.template or not rnd.template.is_enabled:
|
||||
continue
|
||||
tpl_slug = rnd.template.slug if rnd.template else ""
|
||||
if tpl_slug not in unique_templates:
|
||||
unique_templates[tpl_slug] = rnd
|
||||
|
||||
contests = []
|
||||
for tpl_slug, rnd in unique_templates.items():
|
||||
# Check if user already played this round
|
||||
attempt = await get_attempt(db, rnd.id, user.id)
|
||||
|
||||
contests.append(ContestInfo(
|
||||
id=rnd.id,
|
||||
slug=tpl_slug,
|
||||
name=rnd.template.name if rnd.template else tpl_slug,
|
||||
description=rnd.template.description if rnd.template else None,
|
||||
prize_days=rnd.template.prize_days if rnd.template else 0,
|
||||
is_available=True,
|
||||
already_played=attempt is not None,
|
||||
))
|
||||
|
||||
return contests
|
||||
|
||||
|
||||
@router.get("/{round_id}", response_model=ContestGameData)
|
||||
async def get_contest_game(
|
||||
round_id: int,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get game data for a specific contest round."""
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
if not _user_allowed(subscription):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Contests are only available for users with active or trial subscriptions",
|
||||
)
|
||||
|
||||
active_rounds = await get_active_rounds(db)
|
||||
round_obj = next((r for r in active_rounds if r.id == round_id), None)
|
||||
|
||||
if not round_obj:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Contest round not found or already finished",
|
||||
)
|
||||
|
||||
if not round_obj.template or not round_obj.template.is_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="This contest is disabled",
|
||||
)
|
||||
|
||||
# Check if already played
|
||||
attempt = await get_attempt(db, round_id, user.id)
|
||||
if attempt:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="You have already played this round",
|
||||
)
|
||||
|
||||
tpl = round_obj.template
|
||||
game_type = tpl.slug
|
||||
game_data = {}
|
||||
instructions = ""
|
||||
|
||||
if game_type == GAME_QUEST:
|
||||
rows = round_obj.payload.get("rows", 3)
|
||||
cols = round_obj.payload.get("cols", 3)
|
||||
secret = random.randint(0, rows * cols - 1)
|
||||
game_data = {
|
||||
"rows": rows,
|
||||
"cols": cols,
|
||||
"secret": secret,
|
||||
"grid_size": rows * cols,
|
||||
}
|
||||
instructions = "Select one of the nodes in the grid. Find the hidden server!"
|
||||
|
||||
elif game_type == GAME_LOCKS:
|
||||
total = round_obj.payload.get("total", 20)
|
||||
secret = random.randint(0, total - 1)
|
||||
game_data = {
|
||||
"total": total,
|
||||
"secret": secret,
|
||||
}
|
||||
instructions = "Find the unlocked button among the locks!"
|
||||
|
||||
elif game_type == GAME_SERVER:
|
||||
flags = round_obj.payload.get("flags") or []
|
||||
shuffled_flags = flags.copy()
|
||||
random.shuffle(shuffled_flags)
|
||||
game_data = {
|
||||
"flags": shuffled_flags,
|
||||
}
|
||||
instructions = "Choose a server by clicking on a flag!"
|
||||
|
||||
elif game_type == GAME_CIPHER:
|
||||
question = round_obj.payload.get("question", "")
|
||||
game_data = {
|
||||
"question": question,
|
||||
"input_type": "text",
|
||||
}
|
||||
instructions = "Decrypt the cipher and enter the answer!"
|
||||
|
||||
elif game_type == GAME_EMOJI:
|
||||
question = round_obj.payload.get("question", "🤔")
|
||||
emoji_list = question.split()
|
||||
random.shuffle(emoji_list)
|
||||
game_data = {
|
||||
"question": " ".join(emoji_list),
|
||||
"input_type": "text",
|
||||
}
|
||||
instructions = "Guess the service by emojis!"
|
||||
|
||||
elif game_type == GAME_ANAGRAM:
|
||||
letters = round_obj.payload.get("letters", "")
|
||||
game_data = {
|
||||
"letters": letters,
|
||||
"input_type": "text",
|
||||
}
|
||||
instructions = "Make a word from the given letters!"
|
||||
|
||||
elif game_type == GAME_BLITZ:
|
||||
game_data = {
|
||||
"button_text": "I'm here!",
|
||||
}
|
||||
instructions = "Click the button as fast as you can!"
|
||||
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Unknown contest type",
|
||||
)
|
||||
|
||||
return ContestGameData(
|
||||
round_id=round_id,
|
||||
game_type=game_type,
|
||||
game_data=game_data,
|
||||
instructions=instructions,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{round_id}/answer", response_model=ContestResult)
|
||||
async def submit_contest_answer(
|
||||
round_id: int,
|
||||
request: ContestAnswerRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Submit answer for a contest round."""
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
if not _user_allowed(subscription):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Contests are only available for users with active or trial subscriptions",
|
||||
)
|
||||
|
||||
active_rounds = await get_active_rounds(db)
|
||||
round_obj = next((r for r in active_rounds if r.id == round_id), None)
|
||||
|
||||
if not round_obj:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Contest round not found or already finished",
|
||||
)
|
||||
|
||||
# Check if already played
|
||||
attempt = await get_attempt(db, round_id, user.id)
|
||||
if attempt:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="You have already played this round",
|
||||
)
|
||||
|
||||
tpl = round_obj.template
|
||||
answer = request.answer
|
||||
is_winner = False
|
||||
|
||||
# Determine if winner based on game type
|
||||
if tpl.slug == GAME_SERVER:
|
||||
flags = round_obj.payload.get("flags") or []
|
||||
secret_idx = round_obj.payload.get("secret_idx")
|
||||
correct_flag = flags[secret_idx] if secret_idx is not None and secret_idx < len(flags) else ""
|
||||
is_winner = answer == correct_flag
|
||||
|
||||
elif tpl.slug in {GAME_QUEST, GAME_LOCKS}:
|
||||
try:
|
||||
parts = answer.split("_")
|
||||
if len(parts) >= 2:
|
||||
idx = int(parts[0])
|
||||
secret = int(parts[1])
|
||||
is_winner = idx == secret
|
||||
except (ValueError, IndexError):
|
||||
is_winner = False
|
||||
|
||||
elif tpl.slug == GAME_BLITZ:
|
||||
is_winner = answer.lower() == "blitz"
|
||||
|
||||
elif tpl.slug in {GAME_CIPHER, GAME_EMOJI, GAME_ANAGRAM}:
|
||||
correct = (round_obj.payload.get("answer") or "").upper()
|
||||
is_winner = correct and answer.upper() == correct
|
||||
|
||||
# Record attempt
|
||||
await create_attempt(
|
||||
db,
|
||||
round_id=round_obj.id,
|
||||
user_id=user.id,
|
||||
answer=str(answer),
|
||||
is_winner=is_winner
|
||||
)
|
||||
|
||||
if is_winner:
|
||||
await increment_winner_count(db, round_obj)
|
||||
prize_text = await _award_prize(db, user.id, tpl.prize_days)
|
||||
return ContestResult(
|
||||
is_winner=True,
|
||||
message=f"🎉 Congratulations! You won! {prize_text}",
|
||||
prize_days=tpl.prize_days,
|
||||
)
|
||||
else:
|
||||
lose_messages = {
|
||||
GAME_QUEST: ["Empty node", "Wrong server", "Try another"],
|
||||
GAME_LOCKS: ["Locked", "No access", "Try again"],
|
||||
GAME_SERVER: ["Server overloaded", "No response", "Try tomorrow"],
|
||||
}
|
||||
messages = lose_messages.get(tpl.slug, ["Incorrect", "Try again next round"])
|
||||
return ContestResult(
|
||||
is_winner=False,
|
||||
message=random.choice(messages),
|
||||
)
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Info pages routes for cabinet - FAQ, rules, privacy policy, etc."""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.config import settings
|
||||
from app.services.faq_service import FaqService
|
||||
from app.services.privacy_policy_service import PrivacyPolicyService
|
||||
from app.services.public_offer_service import PublicOfferService
|
||||
from app.database.crud.rules import get_rules_by_language, get_current_rules_content
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user, get_optional_cabinet_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/info", tags=["Cabinet Info"])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
class FaqPageResponse(BaseModel):
|
||||
"""FAQ page."""
|
||||
id: int
|
||||
title: str
|
||||
content: str
|
||||
order: int
|
||||
|
||||
|
||||
class RulesResponse(BaseModel):
|
||||
"""Service rules."""
|
||||
content: str
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
class PrivacyPolicyResponse(BaseModel):
|
||||
"""Privacy policy."""
|
||||
content: str
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
class PublicOfferResponse(BaseModel):
|
||||
"""Public offer."""
|
||||
content: str
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
class ServiceInfoResponse(BaseModel):
|
||||
"""General service info."""
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
support_email: Optional[str] = None
|
||||
support_telegram: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
@router.get("/faq", response_model=List[FaqPageResponse])
|
||||
async def get_faq_pages(
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of FAQ pages."""
|
||||
requested_lang = FaqService.normalize_language(language)
|
||||
pages = await FaqService.get_pages(
|
||||
db,
|
||||
requested_lang,
|
||||
include_inactive=False, # Only active pages for cabinet
|
||||
fallback=True,
|
||||
)
|
||||
|
||||
return [
|
||||
FaqPageResponse(
|
||||
id=page.id,
|
||||
title=page.title,
|
||||
content=page.content or "",
|
||||
order=page.display_order or 0,
|
||||
)
|
||||
for page in pages
|
||||
]
|
||||
|
||||
|
||||
@router.get("/faq/{page_id}", response_model=FaqPageResponse)
|
||||
async def get_faq_page(
|
||||
page_id: int,
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get a specific FAQ page by ID."""
|
||||
requested_lang = FaqService.normalize_language(language)
|
||||
page = await FaqService.get_page(
|
||||
db,
|
||||
page_id,
|
||||
requested_lang,
|
||||
include_inactive=False,
|
||||
fallback=True,
|
||||
)
|
||||
|
||||
if not page:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="FAQ page not found",
|
||||
)
|
||||
|
||||
return FaqPageResponse(
|
||||
id=page.id,
|
||||
title=page.title,
|
||||
content=page.content or "",
|
||||
order=page.display_order or 0,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/rules", response_model=RulesResponse)
|
||||
async def get_rules(
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get service rules - uses same function as bot."""
|
||||
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)
|
||||
|
||||
# Try to get updated_at from DB record
|
||||
rules = await get_rules_by_language(db, requested_lang)
|
||||
updated_at = None
|
||||
if rules and rules.updated_at:
|
||||
updated_at = rules.updated_at.isoformat()
|
||||
|
||||
return RulesResponse(content=content, updated_at=updated_at)
|
||||
|
||||
|
||||
@router.get("/privacy-policy", response_model=PrivacyPolicyResponse)
|
||||
async def get_privacy_policy(
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get privacy policy."""
|
||||
requested_lang = PrivacyPolicyService.normalize_language(language)
|
||||
policy = await PrivacyPolicyService.get_policy(db, requested_lang, fallback=True)
|
||||
|
||||
if policy and policy.content:
|
||||
updated_at = policy.updated_at.isoformat() if policy.updated_at else None
|
||||
return PrivacyPolicyResponse(content=policy.content, updated_at=updated_at)
|
||||
|
||||
# Return default policy if none found
|
||||
return PrivacyPolicyResponse(
|
||||
content="""# Политика конфиденциальности
|
||||
|
||||
Мы уважаем вашу конфиденциальность и защищаем ваши персональные данные.
|
||||
""",
|
||||
updated_at=None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/public-offer", response_model=PublicOfferResponse)
|
||||
async def get_public_offer(
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get public offer."""
|
||||
requested_lang = PublicOfferService.normalize_language(language)
|
||||
offer = await PublicOfferService.get_offer(db, requested_lang, fallback=True)
|
||||
|
||||
if offer and offer.content:
|
||||
updated_at = offer.updated_at.isoformat() if offer.updated_at else None
|
||||
return PublicOfferResponse(content=offer.content, updated_at=updated_at)
|
||||
|
||||
# Return default offer if none found
|
||||
return PublicOfferResponse(
|
||||
content="""# Публичная оферта
|
||||
|
||||
Условия использования сервиса.
|
||||
""",
|
||||
updated_at=None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/service", response_model=ServiceInfoResponse)
|
||||
async def get_service_info():
|
||||
"""Get general service information."""
|
||||
return ServiceInfoResponse(
|
||||
name=getattr(settings, 'SERVICE_NAME', None) or getattr(settings, 'BOT_NAME', 'VPN Service'),
|
||||
description=getattr(settings, 'SERVICE_DESCRIPTION', None),
|
||||
support_email=getattr(settings, 'SUPPORT_EMAIL', None),
|
||||
support_telegram=getattr(settings, 'SUPPORT_USERNAME', None) or getattr(settings, 'SUPPORT_TELEGRAM', None),
|
||||
website=getattr(settings, 'WEBSITE_URL', None),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/languages")
|
||||
async def get_available_languages():
|
||||
"""Get list of available languages."""
|
||||
return {
|
||||
"languages": [
|
||||
{"code": "ru", "name": "Русский", "flag": "🇷🇺"},
|
||||
{"code": "en", "name": "English", "flag": "🇬🇧"},
|
||||
],
|
||||
"default": getattr(settings, 'DEFAULT_LANGUAGE', 'ru') or 'ru',
|
||||
}
|
||||
|
||||
|
||||
@router.get("/user/language")
|
||||
async def get_user_language(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
):
|
||||
"""Get current user's language."""
|
||||
return {"language": user.language or "ru"}
|
||||
|
||||
|
||||
@router.patch("/user/language")
|
||||
async def update_user_language(
|
||||
request: Dict[str, str],
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update user's language preference."""
|
||||
language = request.get("language", "ru")
|
||||
|
||||
valid_languages = ["ru", "en"]
|
||||
if language not in valid_languages:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid language. Supported: {', '.join(valid_languages)}",
|
||||
)
|
||||
|
||||
user.language = language
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
return {"language": user.language}
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Notification settings routes for cabinet."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
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 ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/notifications", tags=["Cabinet Notifications"])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
class NotificationSettingsResponse(BaseModel):
|
||||
"""User notification settings."""
|
||||
subscription_expiry_enabled: bool = True
|
||||
subscription_expiry_days: int = 3
|
||||
traffic_warning_enabled: bool = True
|
||||
traffic_warning_percent: int = 80
|
||||
balance_low_enabled: bool = True
|
||||
balance_low_threshold: int = 100 # kopeks
|
||||
news_enabled: bool = True
|
||||
promo_offers_enabled: bool = True
|
||||
|
||||
|
||||
class NotificationSettingsUpdate(BaseModel):
|
||||
"""Update notification settings."""
|
||||
subscription_expiry_enabled: Optional[bool] = None
|
||||
subscription_expiry_days: Optional[int] = Field(None, ge=1, le=30)
|
||||
traffic_warning_enabled: Optional[bool] = None
|
||||
traffic_warning_percent: Optional[int] = Field(None, ge=50, le=99)
|
||||
balance_low_enabled: Optional[bool] = None
|
||||
balance_low_threshold: Optional[int] = Field(None, ge=0)
|
||||
news_enabled: Optional[bool] = None
|
||||
promo_offers_enabled: Optional[bool] = None
|
||||
|
||||
|
||||
# ============ Helpers ============
|
||||
|
||||
def _get_notification_settings(user: User) -> Dict[str, Any]:
|
||||
"""Get notification settings from user object."""
|
||||
# Try to get from user's settings field or use defaults
|
||||
settings_data = getattr(user, 'notification_settings', None) or {}
|
||||
|
||||
return {
|
||||
"subscription_expiry_enabled": settings_data.get("subscription_expiry_enabled", True),
|
||||
"subscription_expiry_days": settings_data.get("subscription_expiry_days", 3),
|
||||
"traffic_warning_enabled": settings_data.get("traffic_warning_enabled", True),
|
||||
"traffic_warning_percent": settings_data.get("traffic_warning_percent", 80),
|
||||
"balance_low_enabled": settings_data.get("balance_low_enabled", True),
|
||||
"balance_low_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),
|
||||
}
|
||||
|
||||
|
||||
def _update_notification_settings(user: User, updates: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Update notification settings on user object."""
|
||||
current_settings = _get_notification_settings(user)
|
||||
|
||||
for key, value in updates.items():
|
||||
if value is not None:
|
||||
current_settings[key] = value
|
||||
|
||||
return current_settings
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
@router.get("", response_model=NotificationSettingsResponse)
|
||||
async def get_notification_settings(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
):
|
||||
"""Get user's notification settings."""
|
||||
settings = _get_notification_settings(user)
|
||||
return NotificationSettingsResponse(**settings)
|
||||
|
||||
|
||||
@router.patch("", response_model=NotificationSettingsResponse)
|
||||
async def update_notification_settings(
|
||||
request: NotificationSettingsUpdate,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update user's notification settings."""
|
||||
updates = request.model_dump(exclude_unset=True)
|
||||
|
||||
if not updates:
|
||||
# No updates provided, return current settings
|
||||
settings = _get_notification_settings(user)
|
||||
return NotificationSettingsResponse(**settings)
|
||||
|
||||
# Update settings
|
||||
new_settings = _update_notification_settings(user, updates)
|
||||
|
||||
# Store in user object
|
||||
if not hasattr(user, 'notification_settings') or user.notification_settings is None:
|
||||
user.notification_settings = {}
|
||||
|
||||
user.notification_settings = new_settings
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
return NotificationSettingsResponse(**new_settings)
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
async def send_test_notification(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
):
|
||||
"""Send a test notification to the user."""
|
||||
# This would typically trigger a notification via Telegram bot
|
||||
# For now, just return success
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Test notification request received. You will receive a test message shortly.",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
async def get_notification_history(
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get user's notification history."""
|
||||
# For now, return empty list - notification history can be implemented later
|
||||
# when there's a notification log table
|
||||
return {
|
||||
"notifications": [],
|
||||
"total": 0,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
"""Polls routes for cabinet - user participation in polls/surveys."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import User, Poll, PollResponse, PollQuestion
|
||||
from app.database.crud.poll import (
|
||||
get_poll_response_by_id,
|
||||
record_poll_answer,
|
||||
)
|
||||
from app.services.poll_service import get_next_question, get_question_option, reward_user_for_poll
|
||||
from app.config import settings
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/polls", tags=["Cabinet Polls"])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
class PollOptionResponse(BaseModel):
|
||||
"""Poll option."""
|
||||
id: int
|
||||
text: str
|
||||
order: int
|
||||
|
||||
|
||||
class PollQuestionResponse(BaseModel):
|
||||
"""Poll question with options."""
|
||||
id: int
|
||||
text: str
|
||||
order: int
|
||||
options: List[PollOptionResponse]
|
||||
|
||||
|
||||
class PollInfo(BaseModel):
|
||||
"""Poll info for user."""
|
||||
id: int
|
||||
response_id: int
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
total_questions: int
|
||||
answered_questions: int
|
||||
is_completed: bool
|
||||
reward_amount: Optional[int] = None
|
||||
|
||||
|
||||
class PollStartResponse(BaseModel):
|
||||
"""Response when starting a poll."""
|
||||
response_id: int
|
||||
current_question_index: int
|
||||
total_questions: int
|
||||
question: PollQuestionResponse
|
||||
|
||||
|
||||
class AnswerRequest(BaseModel):
|
||||
"""Request to answer a poll question."""
|
||||
option_id: int
|
||||
|
||||
|
||||
class AnswerResponse(BaseModel):
|
||||
"""Response after answering."""
|
||||
success: bool
|
||||
is_completed: bool
|
||||
next_question: Optional[PollQuestionResponse] = None
|
||||
current_question_index: Optional[int] = None
|
||||
total_questions: int
|
||||
reward_granted: Optional[int] = None
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
# ============ Helpers ============
|
||||
|
||||
def _question_to_response(question: PollQuestion) -> PollQuestionResponse:
|
||||
"""Convert question model to response."""
|
||||
options = [
|
||||
PollOptionResponse(
|
||||
id=opt.id,
|
||||
text=opt.text,
|
||||
order=opt.order,
|
||||
)
|
||||
for opt in sorted(question.options, key=lambda o: o.order)
|
||||
]
|
||||
return PollQuestionResponse(
|
||||
id=question.id,
|
||||
text=question.text,
|
||||
order=question.order,
|
||||
options=options,
|
||||
)
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
class PollsCountResponse(BaseModel):
|
||||
"""Count of available polls."""
|
||||
count: int
|
||||
|
||||
|
||||
@router.get("/count", response_model=PollsCountResponse)
|
||||
async def get_polls_count(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get count of polls available for the user."""
|
||||
result = await db.execute(
|
||||
select(PollResponse)
|
||||
.where(PollResponse.user_id == user.id)
|
||||
.where(PollResponse.completed_at.is_(None)) # Only incomplete polls
|
||||
)
|
||||
responses = result.scalars().all()
|
||||
return PollsCountResponse(count=len(responses))
|
||||
|
||||
|
||||
@router.get("", response_model=List[PollInfo])
|
||||
async def get_available_polls(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of polls available for the user."""
|
||||
# Get user's poll responses with eager loading of relationships
|
||||
result = await db.execute(
|
||||
select(PollResponse)
|
||||
.where(PollResponse.user_id == user.id)
|
||||
.options(
|
||||
selectinload(PollResponse.poll).selectinload(Poll.questions),
|
||||
selectinload(PollResponse.answers),
|
||||
)
|
||||
.order_by(PollResponse.created_at.desc())
|
||||
)
|
||||
responses = result.scalars().all()
|
||||
|
||||
polls = []
|
||||
for response in responses:
|
||||
if not response.poll:
|
||||
continue
|
||||
|
||||
answered_count = len(response.answers) if response.answers else 0
|
||||
total_questions = len(response.poll.questions) if response.poll.questions else 0
|
||||
|
||||
# Convert kopeks to rubles for display
|
||||
reward_amount = None
|
||||
if response.poll.reward_amount_kopeks:
|
||||
reward_amount = response.poll.reward_amount_kopeks // 100
|
||||
|
||||
polls.append(PollInfo(
|
||||
id=response.poll.id,
|
||||
response_id=response.id,
|
||||
title=response.poll.title,
|
||||
description=response.poll.description,
|
||||
total_questions=total_questions,
|
||||
answered_questions=answered_count,
|
||||
is_completed=response.completed_at is not None,
|
||||
reward_amount=reward_amount,
|
||||
))
|
||||
|
||||
return polls
|
||||
|
||||
|
||||
@router.get("/{response_id}", response_model=PollInfo)
|
||||
async def get_poll_details(
|
||||
response_id: int,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get details of a specific poll response."""
|
||||
response = await get_poll_response_by_id(db, response_id)
|
||||
|
||||
if not response or response.user_id != user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Poll not found",
|
||||
)
|
||||
|
||||
if not response.poll:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Poll data not available",
|
||||
)
|
||||
|
||||
answered_count = len(response.answers) if response.answers else 0
|
||||
total_questions = len(response.poll.questions) if response.poll.questions else 0
|
||||
|
||||
# Convert kopeks to rubles for display
|
||||
reward_amount = None
|
||||
if response.poll.reward_amount_kopeks:
|
||||
reward_amount = response.poll.reward_amount_kopeks // 100
|
||||
|
||||
return PollInfo(
|
||||
id=response.poll.id,
|
||||
response_id=response.id,
|
||||
title=response.poll.title,
|
||||
description=response.poll.description,
|
||||
total_questions=total_questions,
|
||||
answered_questions=answered_count,
|
||||
is_completed=response.completed_at is not None,
|
||||
reward_amount=reward_amount,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{response_id}/start", response_model=PollStartResponse)
|
||||
async def start_poll(
|
||||
response_id: int,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Start or continue a poll."""
|
||||
response = await get_poll_response_by_id(db, response_id)
|
||||
|
||||
if not response or response.user_id != user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Poll not found",
|
||||
)
|
||||
|
||||
if response.completed_at:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="This poll has already been completed",
|
||||
)
|
||||
|
||||
if not response.poll or not response.poll.questions:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Poll is not available",
|
||||
)
|
||||
|
||||
# Mark as started if not already
|
||||
if not response.started_at:
|
||||
response.started_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
# Get next unanswered question
|
||||
index, question = await get_next_question(response)
|
||||
|
||||
if not question:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No questions available",
|
||||
)
|
||||
|
||||
return PollStartResponse(
|
||||
response_id=response.id,
|
||||
current_question_index=index,
|
||||
total_questions=len(response.poll.questions),
|
||||
question=_question_to_response(question),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{response_id}/questions/{question_id}/answer", response_model=AnswerResponse)
|
||||
async def answer_question(
|
||||
response_id: int,
|
||||
question_id: int,
|
||||
request: AnswerRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Submit answer for a poll question."""
|
||||
response = await get_poll_response_by_id(db, response_id)
|
||||
|
||||
if not response or response.user_id != user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Poll not found",
|
||||
)
|
||||
|
||||
if response.completed_at:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="This poll has already been completed",
|
||||
)
|
||||
|
||||
if not response.poll:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Poll is not available",
|
||||
)
|
||||
|
||||
# Find the question
|
||||
question = next((q for q in response.poll.questions if q.id == question_id), None)
|
||||
if not question:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Question not found",
|
||||
)
|
||||
|
||||
# Validate option
|
||||
option = await get_question_option(question, request.option_id)
|
||||
if not option:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid answer option",
|
||||
)
|
||||
|
||||
# Record the answer
|
||||
await record_poll_answer(
|
||||
db,
|
||||
response_id=response.id,
|
||||
question_id=question.id,
|
||||
option_id=option.id,
|
||||
)
|
||||
|
||||
# Refresh to get updated answers
|
||||
try:
|
||||
await db.refresh(response, attribute_names=["answers"])
|
||||
except Exception:
|
||||
response = await get_poll_response_by_id(db, response_id)
|
||||
if not response:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to process answer",
|
||||
)
|
||||
|
||||
# Get next question
|
||||
index, next_question = await get_next_question(response)
|
||||
total_questions = len(response.poll.questions)
|
||||
|
||||
if next_question:
|
||||
# More questions to answer
|
||||
return AnswerResponse(
|
||||
success=True,
|
||||
is_completed=False,
|
||||
next_question=_question_to_response(next_question),
|
||||
current_question_index=index,
|
||||
total_questions=total_questions,
|
||||
)
|
||||
|
||||
# Poll completed
|
||||
response.completed_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
# Award reward if any
|
||||
reward_amount = await reward_user_for_poll(db, response)
|
||||
|
||||
message = "Thank you for completing the poll!"
|
||||
if reward_amount:
|
||||
message += f" Reward of {settings.format_price(reward_amount)} has been added to your balance."
|
||||
|
||||
return AnswerResponse(
|
||||
success=True,
|
||||
is_completed=True,
|
||||
total_questions=total_questions,
|
||||
reward_granted=reward_amount,
|
||||
message=message,
|
||||
)
|
||||
@@ -0,0 +1,306 @@
|
||||
"""Promo offers routes for cabinet - personal discounts and offers."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional, Dict, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_
|
||||
|
||||
from app.database.models import User, DiscountOffer
|
||||
from app.database.crud.discount_offer import (
|
||||
get_offer_by_id,
|
||||
mark_offer_claimed,
|
||||
)
|
||||
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
|
||||
from app.services.promo_offer_service import promo_offer_service
|
||||
from app.config import settings
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/promo", tags=["Cabinet Promo"])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
class PromoOfferInfo(BaseModel):
|
||||
"""Promo offer info."""
|
||||
id: int
|
||||
notification_type: str
|
||||
discount_percent: Optional[int] = None
|
||||
effect_type: str
|
||||
expires_at: datetime
|
||||
is_active: bool
|
||||
is_claimed: bool
|
||||
claimed_at: Optional[datetime] = None
|
||||
extra_data: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class ActiveDiscountInfo(BaseModel):
|
||||
"""User's active discount info."""
|
||||
discount_percent: int
|
||||
source: Optional[str] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
is_active: bool
|
||||
|
||||
|
||||
class ClaimOfferRequest(BaseModel):
|
||||
"""Request to claim an offer."""
|
||||
offer_id: int
|
||||
|
||||
|
||||
class ClaimOfferResponse(BaseModel):
|
||||
"""Response after claiming offer."""
|
||||
success: bool
|
||||
message: str
|
||||
discount_percent: Optional[int] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class PromoGroupDiscounts(BaseModel):
|
||||
"""User's promo group discounts."""
|
||||
group_name: Optional[str] = None
|
||||
server_discount_percent: int = 0
|
||||
traffic_discount_percent: int = 0
|
||||
device_discount_percent: int = 0
|
||||
period_discounts: Dict[str, int] = {}
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
@router.get("/offers", response_model=List[PromoOfferInfo])
|
||||
async def get_promo_offers(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of available promo offers for the user."""
|
||||
now = datetime.utcnow()
|
||||
|
||||
result = await db.execute(
|
||||
select(DiscountOffer)
|
||||
.where(
|
||||
and_(
|
||||
DiscountOffer.user_id == user.id,
|
||||
DiscountOffer.expires_at > now,
|
||||
)
|
||||
)
|
||||
.order_by(DiscountOffer.created_at.desc())
|
||||
)
|
||||
offers = result.scalars().all()
|
||||
|
||||
return [
|
||||
PromoOfferInfo(
|
||||
id=offer.id,
|
||||
notification_type=offer.notification_type or "",
|
||||
discount_percent=offer.discount_percent,
|
||||
effect_type=offer.effect_type or "percent_discount",
|
||||
expires_at=offer.expires_at,
|
||||
is_active=offer.is_active and offer.claimed_at is None,
|
||||
is_claimed=offer.claimed_at is not None,
|
||||
claimed_at=offer.claimed_at,
|
||||
extra_data=offer.extra_data,
|
||||
)
|
||||
for offer in offers
|
||||
]
|
||||
|
||||
|
||||
@router.get("/active-discount", response_model=ActiveDiscountInfo)
|
||||
async def get_active_discount(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
):
|
||||
"""Get user's currently active discount."""
|
||||
discount_percent = user.promo_offer_discount_percent or 0
|
||||
expires_at = user.promo_offer_discount_expires_at
|
||||
source = user.promo_offer_discount_source
|
||||
|
||||
now = datetime.utcnow()
|
||||
is_active = discount_percent > 0 and (expires_at is None or expires_at > now)
|
||||
|
||||
return ActiveDiscountInfo(
|
||||
discount_percent=discount_percent if is_active else 0,
|
||||
source=source,
|
||||
expires_at=expires_at,
|
||||
is_active=is_active,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/group-discounts", response_model=PromoGroupDiscounts)
|
||||
async def get_promo_group_discounts(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get user's promo group discounts."""
|
||||
await db.refresh(user, ["promo_groups"])
|
||||
|
||||
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
|
||||
|
||||
if not promo_group:
|
||||
return PromoGroupDiscounts()
|
||||
|
||||
# Get period discounts
|
||||
period_discounts = {}
|
||||
raw_period_discounts = getattr(promo_group, "period_discounts", None)
|
||||
if isinstance(raw_period_discounts, dict):
|
||||
for key, value in raw_period_discounts.items():
|
||||
try:
|
||||
period_discounts[str(key)] = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
return PromoGroupDiscounts(
|
||||
group_name=promo_group.name,
|
||||
server_discount_percent=promo_group.server_discount_percent or 0,
|
||||
traffic_discount_percent=promo_group.traffic_discount_percent or 0,
|
||||
device_discount_percent=promo_group.device_discount_percent or 0,
|
||||
period_discounts=period_discounts,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/claim", response_model=ClaimOfferResponse)
|
||||
async def claim_promo_offer(
|
||||
request: ClaimOfferRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Claim a promo offer."""
|
||||
offer = await get_offer_by_id(db, request.offer_id)
|
||||
|
||||
if not offer or offer.user_id != user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Offer not found",
|
||||
)
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
if offer.claimed_at is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="This offer has already been claimed",
|
||||
)
|
||||
|
||||
if not offer.is_active or offer.expires_at <= now:
|
||||
offer.is_active = False
|
||||
await db.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="This offer has expired",
|
||||
)
|
||||
|
||||
effect_type = (offer.effect_type or "percent_discount").lower()
|
||||
|
||||
# Handle test access offers
|
||||
if effect_type == "test_access":
|
||||
await db.refresh(user, ["subscription"])
|
||||
success, newly_added, expires_at, error_code = await promo_offer_service.grant_test_access(
|
||||
db,
|
||||
user,
|
||||
offer,
|
||||
)
|
||||
|
||||
if not success:
|
||||
error_messages = {
|
||||
"subscription_missing": "Active subscription required for this offer",
|
||||
"squads_missing": "Could not determine servers for test access",
|
||||
"already_connected": "These servers are already connected",
|
||||
"remnawave_sync_failed": "Failed to connect servers. Please try again later",
|
||||
}
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error_messages.get(error_code, "Failed to activate offer"),
|
||||
)
|
||||
|
||||
await mark_offer_claimed(
|
||||
db,
|
||||
offer,
|
||||
details={
|
||||
"context": "test_access_claim",
|
||||
"new_squads": newly_added,
|
||||
"expires_at": expires_at.isoformat() if expires_at else None,
|
||||
},
|
||||
)
|
||||
|
||||
return ClaimOfferResponse(
|
||||
success=True,
|
||||
message=f"Test access activated until {expires_at.strftime('%Y-%m-%d %H:%M') if expires_at else 'unlimited'}",
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
# Handle discount offers
|
||||
discount_percent = int(offer.discount_percent or 0)
|
||||
if discount_percent <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid offer",
|
||||
)
|
||||
|
||||
user.promo_offer_discount_percent = discount_percent
|
||||
user.promo_offer_discount_source = offer.notification_type
|
||||
user.updated_at = now
|
||||
|
||||
# Calculate expiration
|
||||
extra_data = offer.extra_data or {}
|
||||
raw_duration = extra_data.get("active_discount_hours")
|
||||
template_id = extra_data.get("template_id")
|
||||
|
||||
if raw_duration in (None, "") and template_id:
|
||||
try:
|
||||
template = await get_promo_offer_template_by_id(db, int(template_id))
|
||||
except (ValueError, TypeError):
|
||||
template = None
|
||||
if template and template.active_discount_hours:
|
||||
raw_duration = template.active_discount_hours
|
||||
|
||||
try:
|
||||
duration_hours = int(raw_duration) if raw_duration is not None else None
|
||||
except (TypeError, ValueError):
|
||||
duration_hours = None
|
||||
|
||||
if duration_hours and duration_hours > 0:
|
||||
discount_expires_at = now + timedelta(hours=duration_hours)
|
||||
else:
|
||||
discount_expires_at = None
|
||||
|
||||
user.promo_offer_discount_expires_at = discount_expires_at
|
||||
|
||||
await mark_offer_claimed(
|
||||
db,
|
||||
offer,
|
||||
details={
|
||||
"context": "discount_claim",
|
||||
"discount_percent": discount_percent,
|
||||
"discount_expires_at": discount_expires_at.isoformat() if discount_expires_at else None,
|
||||
},
|
||||
)
|
||||
await db.refresh(user)
|
||||
|
||||
expires_text = ""
|
||||
if discount_expires_at:
|
||||
expires_text = f" Valid until {discount_expires_at.strftime('%Y-%m-%d %H:%M')}"
|
||||
|
||||
return ClaimOfferResponse(
|
||||
success=True,
|
||||
message=f"Discount of {discount_percent}% activated!{expires_text}",
|
||||
discount_percent=discount_percent,
|
||||
expires_at=discount_expires_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/active-discount")
|
||||
async def clear_active_discount(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Clear user's 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.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Active discount cleared"}
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Promo code routes for cabinet."""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
|
||||
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.services.promocode_service import PromoCodeService
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/promocode", tags=["Cabinet Promocode"])
|
||||
|
||||
|
||||
class PromocodeActivateRequest(BaseModel):
|
||||
"""Request to activate a promo code."""
|
||||
code: str = Field(..., min_length=1, max_length=50, description="Promo code to activate")
|
||||
|
||||
|
||||
class PromocodeActivateResponse(BaseModel):
|
||||
"""Response after activating a promo code."""
|
||||
success: bool
|
||||
message: str
|
||||
balance_before: float = 0
|
||||
balance_after: float = 0
|
||||
bonus_description: str | None = None
|
||||
|
||||
|
||||
@router.post("/activate", response_model=PromocodeActivateResponse)
|
||||
async def activate_promocode(
|
||||
request: PromocodeActivateRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""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()
|
||||
)
|
||||
|
||||
if result["success"]:
|
||||
balance_before_rubles = result.get("balance_before_kopeks", 0) / 100
|
||||
balance_after_rubles = result.get("balance_after_kopeks", 0) / 100
|
||||
|
||||
return PromocodeActivateResponse(
|
||||
success=True,
|
||||
message="Promo code activated successfully",
|
||||
balance_before=balance_before_rubles,
|
||||
balance_after=balance_after_rubles,
|
||||
bonus_description=result.get("description"),
|
||||
)
|
||||
|
||||
# Map error codes to messages
|
||||
error_messages = {
|
||||
"not_found": "Promo code not found",
|
||||
"expired": "Promo code has expired",
|
||||
"used": "Promo code has been fully used",
|
||||
"already_used_by_user": "You have already used this promo code",
|
||||
"user_not_found": "User not found",
|
||||
"server_error": "Server error occurred",
|
||||
}
|
||||
|
||||
error_code = result.get("error", "server_error")
|
||||
error_message = error_messages.get(error_code, "Failed to activate promo code")
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error_message,
|
||||
)
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Referral program routes for cabinet."""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc
|
||||
|
||||
from app.database.models import User, ReferralEarning
|
||||
from app.config import settings
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.referral import (
|
||||
ReferralInfoResponse,
|
||||
ReferralItemResponse,
|
||||
ReferralListResponse,
|
||||
ReferralEarningResponse,
|
||||
ReferralEarningsListResponse,
|
||||
ReferralTermsResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/referral", tags=["Cabinet Referral"])
|
||||
|
||||
|
||||
@router.get("", response_model=ReferralInfoResponse)
|
||||
async def get_referral_info(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get referral program info for current user."""
|
||||
# Get total referrals count
|
||||
total_query = select(func.count()).select_from(User).where(User.referred_by_id == user.id)
|
||||
total_result = await db.execute(total_query)
|
||||
total_referrals = total_result.scalar() or 0
|
||||
|
||||
# Get active referrals (with subscription)
|
||||
active_query = (
|
||||
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
|
||||
|
||||
# Get total earnings
|
||||
earnings_query = (
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
|
||||
.where(ReferralEarning.user_id == user.id)
|
||||
)
|
||||
earnings_result = await db.execute(earnings_query)
|
||||
total_earnings = earnings_result.scalar() or 0
|
||||
|
||||
# Get user's commission percent
|
||||
commission_percent = user.referral_commission_percent
|
||||
if commission_percent is None:
|
||||
commission_percent = settings.REFERRAL_COMMISSION_PERCENT
|
||||
|
||||
# Build referral link
|
||||
bot_username = settings.get_bot_username() or "bot"
|
||||
referral_link = f"https://t.me/{bot_username}?start={user.referral_code}"
|
||||
|
||||
return ReferralInfoResponse(
|
||||
referral_code=user.referral_code or "",
|
||||
referral_link=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,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/list", response_model=ReferralListResponse)
|
||||
async def get_referral_list(
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
per_page: int = Query(20, ge=1, le=100, description="Items per page"),
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of invited users."""
|
||||
# Base query
|
||||
query = select(User).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)
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Paginate
|
||||
offset = (page - 1) * per_page
|
||||
query = query.order_by(desc(User.created_at)).offset(offset).limit(per_page)
|
||||
|
||||
result = await db.execute(query)
|
||||
referrals = result.scalars().all()
|
||||
|
||||
items = [
|
||||
ReferralItemResponse(
|
||||
id=r.id,
|
||||
username=r.username,
|
||||
first_name=r.first_name,
|
||||
created_at=r.created_at,
|
||||
has_subscription=r.subscription is not None,
|
||||
has_paid=r.has_had_paid_subscription,
|
||||
)
|
||||
for r in referrals
|
||||
]
|
||||
|
||||
pages = math.ceil(total / per_page) if total > 0 else 1
|
||||
|
||||
return ReferralListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/earnings", response_model=ReferralEarningsListResponse)
|
||||
async def get_referral_earnings(
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
per_page: int = Query(20, ge=1, le=100, description="Items per page"),
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get referral earnings history."""
|
||||
# Base query
|
||||
query = select(ReferralEarning).where(ReferralEarning.user_id == user.id)
|
||||
|
||||
# Get total count and sum
|
||||
count_query = select(func.count()).select_from(ReferralEarning).where(ReferralEarning.user_id == user.id)
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
sum_query = (
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
|
||||
.where(ReferralEarning.user_id == user.id)
|
||||
)
|
||||
sum_result = await db.execute(sum_query)
|
||||
total_amount = sum_result.scalar() or 0
|
||||
|
||||
# Paginate
|
||||
offset = (page - 1) * per_page
|
||||
query = query.order_by(desc(ReferralEarning.created_at)).offset(offset).limit(per_page)
|
||||
|
||||
result = await db.execute(query)
|
||||
earnings = result.scalars().all()
|
||||
|
||||
items = []
|
||||
for e in earnings:
|
||||
# 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(
|
||||
id=e.id,
|
||||
amount_kopeks=e.amount_kopeks,
|
||||
amount_rubles=e.amount_kopeks / 100,
|
||||
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,
|
||||
created_at=e.created_at,
|
||||
))
|
||||
|
||||
pages = math.ceil(total / per_page) if total > 0 else 1
|
||||
|
||||
return ReferralEarningsListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
total_amount_kopeks=total_amount,
|
||||
total_amount_rubles=total_amount / 100,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/terms", response_model=ReferralTermsResponse)
|
||||
async def get_referral_terms():
|
||||
"""Get referral program terms."""
|
||||
return ReferralTermsResponse(
|
||||
is_enabled=settings.is_referral_program_enabled(),
|
||||
commission_percent=settings.REFERRAL_COMMISSION_PERCENT,
|
||||
minimum_topup_kopeks=settings.REFERRAL_MINIMUM_TOPUP_KOPEKS,
|
||||
minimum_topup_rubles=settings.REFERRAL_MINIMUM_TOPUP_KOPEKS / 100,
|
||||
first_topup_bonus_kopeks=settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS,
|
||||
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,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,264 @@
|
||||
"""Support tickets routes for cabinet."""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import User, Ticket, TicketMessage
|
||||
from app.config import settings
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.tickets import (
|
||||
TicketResponse,
|
||||
TicketDetailResponse,
|
||||
TicketListResponse,
|
||||
TicketMessageResponse,
|
||||
TicketCreateRequest,
|
||||
TicketMessageCreateRequest,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/tickets", tags=["Cabinet Tickets"])
|
||||
|
||||
|
||||
def _message_to_response(message: TicketMessage) -> TicketMessageResponse:
|
||||
"""Convert TicketMessage to response."""
|
||||
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),
|
||||
media_type=message.media_type,
|
||||
media_caption=message.media_caption,
|
||||
created_at=message.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _ticket_to_response(ticket: Ticket, include_last_message: bool = True) -> TicketResponse:
|
||||
"""Convert Ticket to response."""
|
||||
last_message = None
|
||||
messages_count = len(ticket.messages) if ticket.messages else 0
|
||||
|
||||
if include_last_message and ticket.messages:
|
||||
last_msg = max(ticket.messages, key=lambda m: m.created_at)
|
||||
last_message = _message_to_response(last_msg)
|
||||
|
||||
return TicketResponse(
|
||||
id=ticket.id,
|
||||
title=ticket.title or f"Ticket #{ticket.id}",
|
||||
status=ticket.status,
|
||||
priority=ticket.priority or "normal",
|
||||
created_at=ticket.created_at,
|
||||
updated_at=ticket.updated_at or ticket.created_at,
|
||||
closed_at=ticket.closed_at,
|
||||
messages_count=messages_count,
|
||||
last_message=last_message,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=TicketListResponse)
|
||||
async def get_tickets(
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
per_page: int = Query(20, ge=1, le=100, description="Items per page"),
|
||||
status_filter: Optional[str] = Query(None, alias="status", description="Filter by status"),
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get user's support tickets."""
|
||||
# Check if tickets are enabled
|
||||
if not settings.is_support_tickets_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Support tickets are disabled",
|
||||
)
|
||||
|
||||
# Base query
|
||||
query = (
|
||||
select(Ticket)
|
||||
.where(Ticket.user_id == user.id)
|
||||
.options(selectinload(Ticket.messages))
|
||||
)
|
||||
|
||||
# Filter by status
|
||||
if status_filter:
|
||||
query = query.where(Ticket.status == status_filter)
|
||||
|
||||
# Get total count
|
||||
count_query = select(func.count()).select_from(Ticket).where(Ticket.user_id == user.id)
|
||||
if status_filter:
|
||||
count_query = count_query.where(Ticket.status == status_filter)
|
||||
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Paginate
|
||||
offset = (page - 1) * per_page
|
||||
query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(per_page)
|
||||
|
||||
result = await db.execute(query)
|
||||
tickets = result.scalars().all()
|
||||
|
||||
items = [_ticket_to_response(t) for t in tickets]
|
||||
pages = math.ceil(total / per_page) if total > 0 else 1
|
||||
|
||||
return TicketListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=TicketDetailResponse)
|
||||
async def create_ticket(
|
||||
request: TicketCreateRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Create a new support ticket."""
|
||||
# Check if tickets are enabled
|
||||
if not settings.is_support_tickets_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Support tickets are disabled",
|
||||
)
|
||||
|
||||
# Create ticket
|
||||
ticket = Ticket(
|
||||
user_id=user.id,
|
||||
title=request.title,
|
||||
status="open",
|
||||
priority="normal",
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(ticket)
|
||||
await db.flush()
|
||||
|
||||
# Create initial message
|
||||
message = TicketMessage(
|
||||
ticket_id=ticket.id,
|
||||
user_id=user.id,
|
||||
message_text=request.message,
|
||||
is_from_admin=False,
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(message)
|
||||
await db.commit()
|
||||
|
||||
# Refresh to get relationships
|
||||
await db.refresh(ticket, ["messages"])
|
||||
|
||||
messages = [_message_to_response(m) for m in ticket.messages]
|
||||
|
||||
return TicketDetailResponse(
|
||||
id=ticket.id,
|
||||
title=ticket.title,
|
||||
status=ticket.status,
|
||||
priority=ticket.priority or "normal",
|
||||
created_at=ticket.created_at,
|
||||
updated_at=ticket.updated_at,
|
||||
closed_at=ticket.closed_at,
|
||||
is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, "is_reply_blocked") else False,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{ticket_id}", response_model=TicketDetailResponse)
|
||||
async def get_ticket(
|
||||
ticket_id: int,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get ticket with all messages."""
|
||||
query = (
|
||||
select(Ticket)
|
||||
.where(Ticket.id == ticket_id, Ticket.user_id == user.id)
|
||||
.options(selectinload(Ticket.messages))
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
ticket = result.scalar_one_or_none()
|
||||
|
||||
if not ticket:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Ticket not found",
|
||||
)
|
||||
|
||||
messages = sorted(ticket.messages or [], key=lambda m: m.created_at)
|
||||
messages_response = [_message_to_response(m) for m in messages]
|
||||
|
||||
return TicketDetailResponse(
|
||||
id=ticket.id,
|
||||
title=ticket.title or f"Ticket #{ticket.id}",
|
||||
status=ticket.status,
|
||||
priority=ticket.priority or "normal",
|
||||
created_at=ticket.created_at,
|
||||
updated_at=ticket.updated_at or ticket.created_at,
|
||||
closed_at=ticket.closed_at,
|
||||
is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, "is_reply_blocked") else False,
|
||||
messages=messages_response,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/messages", response_model=TicketMessageResponse)
|
||||
async def add_ticket_message(
|
||||
ticket_id: int,
|
||||
request: TicketMessageCreateRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Add message to existing ticket."""
|
||||
# Get ticket
|
||||
query = select(Ticket).where(Ticket.id == ticket_id, Ticket.user_id == user.id)
|
||||
result = await db.execute(query)
|
||||
ticket = result.scalar_one_or_none()
|
||||
|
||||
if not ticket:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Ticket not found",
|
||||
)
|
||||
|
||||
# Check if ticket is closed
|
||||
if ticket.status == "closed":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot add message to closed ticket",
|
||||
)
|
||||
|
||||
# Check if replies are blocked
|
||||
if hasattr(ticket, "is_reply_blocked") and ticket.is_reply_blocked:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Replies to this ticket are blocked",
|
||||
)
|
||||
|
||||
# Create message
|
||||
message = TicketMessage(
|
||||
ticket_id=ticket.id,
|
||||
user_id=user.id,
|
||||
message_text=request.message,
|
||||
is_from_admin=False,
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(message)
|
||||
|
||||
# Update ticket status and timestamp
|
||||
if ticket.status == "answered":
|
||||
ticket.status = "pending"
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
|
||||
return _message_to_response(message)
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Cabinet Pydantic schemas."""
|
||||
|
||||
from .auth import (
|
||||
TelegramAuthRequest,
|
||||
TelegramWidgetAuthRequest,
|
||||
EmailRegisterRequest,
|
||||
EmailVerifyRequest,
|
||||
EmailLoginRequest,
|
||||
RefreshTokenRequest,
|
||||
PasswordForgotRequest,
|
||||
PasswordResetRequest,
|
||||
TokenResponse,
|
||||
UserResponse,
|
||||
AuthResponse,
|
||||
)
|
||||
from .subscription import (
|
||||
SubscriptionResponse,
|
||||
RenewalOptionResponse,
|
||||
RenewalRequest,
|
||||
TrafficPackageResponse,
|
||||
TrafficPurchaseRequest,
|
||||
DevicePurchaseRequest,
|
||||
AutopayUpdateRequest,
|
||||
)
|
||||
from .balance import (
|
||||
BalanceResponse,
|
||||
TransactionResponse,
|
||||
TransactionListResponse,
|
||||
PaymentMethodResponse,
|
||||
TopUpRequest,
|
||||
TopUpResponse,
|
||||
)
|
||||
from .referral import (
|
||||
ReferralInfoResponse,
|
||||
ReferralListResponse,
|
||||
ReferralEarningResponse,
|
||||
ReferralTermsResponse,
|
||||
)
|
||||
from .tickets import (
|
||||
TicketResponse,
|
||||
TicketListResponse,
|
||||
TicketMessageResponse,
|
||||
TicketCreateRequest,
|
||||
TicketMessageCreateRequest,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Auth
|
||||
"TelegramAuthRequest",
|
||||
"TelegramWidgetAuthRequest",
|
||||
"EmailRegisterRequest",
|
||||
"EmailVerifyRequest",
|
||||
"EmailLoginRequest",
|
||||
"RefreshTokenRequest",
|
||||
"PasswordForgotRequest",
|
||||
"PasswordResetRequest",
|
||||
"TokenResponse",
|
||||
"UserResponse",
|
||||
"AuthResponse",
|
||||
# Subscription
|
||||
"SubscriptionResponse",
|
||||
"RenewalOptionResponse",
|
||||
"RenewalRequest",
|
||||
"TrafficPackageResponse",
|
||||
"TrafficPurchaseRequest",
|
||||
"DevicePurchaseRequest",
|
||||
"AutopayUpdateRequest",
|
||||
# Balance
|
||||
"BalanceResponse",
|
||||
"TransactionResponse",
|
||||
"TransactionListResponse",
|
||||
"PaymentMethodResponse",
|
||||
"TopUpRequest",
|
||||
"TopUpResponse",
|
||||
# Referral
|
||||
"ReferralInfoResponse",
|
||||
"ReferralListResponse",
|
||||
"ReferralEarningResponse",
|
||||
"ReferralTermsResponse",
|
||||
# Tickets
|
||||
"TicketResponse",
|
||||
"TicketListResponse",
|
||||
"TicketMessageResponse",
|
||||
"TicketCreateRequest",
|
||||
"TicketMessageCreateRequest",
|
||||
]
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Authentication schemas for cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
class TelegramAuthRequest(BaseModel):
|
||||
"""Request for Telegram WebApp initData authentication."""
|
||||
init_data: str = Field(..., description="Telegram WebApp initData string")
|
||||
|
||||
|
||||
class TelegramWidgetAuthRequest(BaseModel):
|
||||
"""Request for Telegram Login Widget authentication."""
|
||||
id: int = Field(..., description="Telegram user ID")
|
||||
first_name: str = Field(..., description="User's first name")
|
||||
last_name: Optional[str] = Field(None, description="User's last name")
|
||||
username: Optional[str] = Field(None, description="User's username")
|
||||
photo_url: Optional[str] = Field(None, description="User's photo URL")
|
||||
auth_date: int = Field(..., description="Unix timestamp of authentication")
|
||||
hash: str = Field(..., description="Authentication hash")
|
||||
|
||||
|
||||
class EmailRegisterRequest(BaseModel):
|
||||
"""Request to register/link email to existing Telegram account."""
|
||||
email: EmailStr = Field(..., description="Email address")
|
||||
password: str = Field(..., min_length=8, max_length=128, description="Password (min 8 chars)")
|
||||
|
||||
|
||||
class EmailVerifyRequest(BaseModel):
|
||||
"""Request to verify email with token."""
|
||||
token: str = Field(..., description="Email verification token")
|
||||
|
||||
|
||||
class EmailLoginRequest(BaseModel):
|
||||
"""Request to login with email and password."""
|
||||
email: EmailStr = Field(..., description="Email address")
|
||||
password: str = Field(..., description="Password")
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
"""Request to refresh access token."""
|
||||
refresh_token: str = Field(..., description="Refresh token")
|
||||
|
||||
|
||||
class PasswordForgotRequest(BaseModel):
|
||||
"""Request to initiate password reset."""
|
||||
email: EmailStr = Field(..., description="Email address")
|
||||
|
||||
|
||||
class PasswordResetRequest(BaseModel):
|
||||
"""Request to reset password with token."""
|
||||
token: str = Field(..., description="Password reset token")
|
||||
password: str = Field(..., min_length=8, max_length=128, description="New password (min 8 chars)")
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""Token pair response."""
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int = Field(..., description="Access token expiration in seconds")
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""User data response."""
|
||||
id: int
|
||||
telegram_id: int
|
||||
username: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
email_verified: bool = False
|
||||
balance_kopeks: int = 0
|
||||
balance_rubles: float = 0.0
|
||||
referral_code: Optional[str] = None
|
||||
language: str = "ru"
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AuthResponse(BaseModel):
|
||||
"""Full authentication response with tokens and user."""
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
user: UserResponse
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Balance and payment schemas for cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class BalanceResponse(BaseModel):
|
||||
"""User balance data."""
|
||||
balance_kopeks: int
|
||||
balance_rubles: float
|
||||
|
||||
|
||||
class TransactionResponse(BaseModel):
|
||||
"""Transaction history item."""
|
||||
id: int
|
||||
type: str
|
||||
amount_kopeks: int
|
||||
amount_rubles: float
|
||||
description: Optional[str] = None
|
||||
payment_method: Optional[str] = None
|
||||
is_completed: bool
|
||||
created_at: datetime
|
||||
completed_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TransactionListResponse(BaseModel):
|
||||
"""Paginated transaction list."""
|
||||
items: List[TransactionResponse]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
pages: int
|
||||
|
||||
|
||||
class PaymentMethodResponse(BaseModel):
|
||||
"""Available payment method."""
|
||||
id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
min_amount_kopeks: int
|
||||
max_amount_kopeks: int
|
||||
is_available: bool = True
|
||||
|
||||
|
||||
class TopUpRequest(BaseModel):
|
||||
"""Request to create payment for balance top-up."""
|
||||
amount_kopeks: int = Field(..., ge=1000, description="Amount in kopeks (min 10 rubles)")
|
||||
payment_method: str = Field(..., description="Payment method ID")
|
||||
payment_option: Optional[str] = Field(None, description="Payment option (e.g. Platega method code)")
|
||||
|
||||
|
||||
class TopUpResponse(BaseModel):
|
||||
"""Response with payment info."""
|
||||
payment_id: str
|
||||
payment_url: str
|
||||
amount_kopeks: int
|
||||
amount_rubles: float
|
||||
status: str
|
||||
expires_at: Optional[datetime] = None
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Referral program schemas for cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ReferralInfoResponse(BaseModel):
|
||||
"""Referral program info for current user."""
|
||||
referral_code: str
|
||||
referral_link: str
|
||||
total_referrals: int
|
||||
active_referrals: int
|
||||
total_earnings_kopeks: int
|
||||
total_earnings_rubles: float
|
||||
commission_percent: int
|
||||
|
||||
|
||||
class ReferralItemResponse(BaseModel):
|
||||
"""Single referral info."""
|
||||
id: int
|
||||
username: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
created_at: datetime
|
||||
has_subscription: bool
|
||||
has_paid: bool
|
||||
|
||||
|
||||
class ReferralListResponse(BaseModel):
|
||||
"""Paginated referral list."""
|
||||
items: List[ReferralItemResponse]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
pages: int
|
||||
|
||||
|
||||
class ReferralEarningResponse(BaseModel):
|
||||
"""Referral earning history item."""
|
||||
id: int
|
||||
amount_kopeks: int
|
||||
amount_rubles: float
|
||||
reason: str
|
||||
referral_username: Optional[str] = None
|
||||
referral_first_name: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ReferralEarningsListResponse(BaseModel):
|
||||
"""Paginated referral earnings list."""
|
||||
items: List[ReferralEarningResponse]
|
||||
total: int
|
||||
total_amount_kopeks: int
|
||||
total_amount_rubles: float
|
||||
page: int
|
||||
per_page: int
|
||||
pages: int
|
||||
|
||||
|
||||
class ReferralTermsResponse(BaseModel):
|
||||
"""Referral program terms."""
|
||||
is_enabled: bool
|
||||
commission_percent: int
|
||||
minimum_topup_kopeks: int
|
||||
minimum_topup_rubles: float
|
||||
first_topup_bonus_kopeks: int
|
||||
first_topup_bonus_rubles: float
|
||||
inviter_bonus_kopeks: int
|
||||
inviter_bonus_rubles: float
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Subscription schemas for cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ServerInfo(BaseModel):
|
||||
"""Server info for display."""
|
||||
uuid: str
|
||||
name: str
|
||||
country_code: Optional[str] = None
|
||||
|
||||
|
||||
class SubscriptionResponse(BaseModel):
|
||||
"""User subscription data."""
|
||||
id: int
|
||||
status: str
|
||||
is_trial: bool
|
||||
start_date: datetime
|
||||
end_date: datetime
|
||||
days_left: int
|
||||
hours_left: int = 0
|
||||
minutes_left: int = 0
|
||||
time_left_display: str = "" # Human readable format like "2д 5ч" or "5ч 30м"
|
||||
traffic_limit_gb: int
|
||||
traffic_used_gb: float
|
||||
traffic_used_percent: float
|
||||
device_limit: int
|
||||
connected_squads: List[str] = []
|
||||
servers: List[ServerInfo] = [] # Server display info
|
||||
autopay_enabled: bool
|
||||
autopay_days_before: int
|
||||
subscription_url: Optional[str] = None
|
||||
is_active: bool
|
||||
is_expired: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class RenewalOptionResponse(BaseModel):
|
||||
"""Available subscription renewal option."""
|
||||
period_days: int
|
||||
price_kopeks: int
|
||||
price_rubles: float
|
||||
discount_percent: int = 0
|
||||
original_price_kopeks: Optional[int] = None
|
||||
|
||||
|
||||
class RenewalRequest(BaseModel):
|
||||
"""Request to renew subscription."""
|
||||
period_days: int = Field(..., description="Renewal period in days")
|
||||
|
||||
|
||||
class TrafficPackageResponse(BaseModel):
|
||||
"""Available traffic package."""
|
||||
gb: int
|
||||
price_kopeks: int
|
||||
price_rubles: float
|
||||
is_unlimited: bool = False
|
||||
|
||||
|
||||
class TrafficPurchaseRequest(BaseModel):
|
||||
"""Request to purchase additional traffic."""
|
||||
gb: int = Field(..., ge=0, description="GB to purchase (0 = unlimited)")
|
||||
|
||||
|
||||
class DevicePurchaseRequest(BaseModel):
|
||||
"""Request to purchase additional device slots."""
|
||||
devices: int = Field(..., ge=1, description="Number of additional devices")
|
||||
|
||||
|
||||
class AutopayUpdateRequest(BaseModel):
|
||||
"""Request to update autopay settings."""
|
||||
enabled: bool
|
||||
days_before: Optional[int] = Field(None, ge=1, le=30, description="Days before expiration to charge")
|
||||
|
||||
|
||||
class TrialInfoResponse(BaseModel):
|
||||
"""Trial subscription info."""
|
||||
is_available: bool
|
||||
duration_days: int
|
||||
traffic_limit_gb: int
|
||||
device_limit: int
|
||||
requires_payment: bool = False
|
||||
price_kopeks: int = 0
|
||||
price_rubles: float = 0.0
|
||||
reason_unavailable: Optional[str] = None
|
||||
|
||||
|
||||
# ============ Purchase Options Schemas ============
|
||||
|
||||
class PurchaseSelectionRequest(BaseModel):
|
||||
"""User's selection for subscription purchase."""
|
||||
period_id: Optional[str] = Field(None, description="Period ID like 'days:30'")
|
||||
period_days: Optional[int] = Field(None, description="Period in days")
|
||||
traffic_value: Optional[int] = Field(None, description="Traffic in GB (0 = unlimited)")
|
||||
servers: Optional[List[str]] = Field(default_factory=list, description="Server UUIDs")
|
||||
devices: Optional[int] = Field(None, description="Device limit")
|
||||
|
||||
|
||||
class PurchasePreviewRequest(BaseModel):
|
||||
"""Request to preview purchase pricing."""
|
||||
selection: PurchaseSelectionRequest
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Support tickets schemas for cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TicketMessageResponse(BaseModel):
|
||||
"""Ticket message data."""
|
||||
id: int
|
||||
message_text: str
|
||||
is_from_admin: bool
|
||||
has_media: bool = False
|
||||
media_type: Optional[str] = None
|
||||
media_caption: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TicketResponse(BaseModel):
|
||||
"""Ticket data."""
|
||||
id: int
|
||||
title: str
|
||||
status: str
|
||||
priority: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
closed_at: Optional[datetime] = None
|
||||
messages_count: int = 0
|
||||
last_message: Optional[TicketMessageResponse] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TicketDetailResponse(BaseModel):
|
||||
"""Ticket with all messages."""
|
||||
id: int
|
||||
title: str
|
||||
status: str
|
||||
priority: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
closed_at: Optional[datetime] = None
|
||||
is_reply_blocked: bool = False
|
||||
messages: List[TicketMessageResponse] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TicketListResponse(BaseModel):
|
||||
"""Paginated ticket list."""
|
||||
items: List[TicketResponse]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
pages: int
|
||||
|
||||
|
||||
class TicketCreateRequest(BaseModel):
|
||||
"""Request to create a new ticket."""
|
||||
title: str = Field(..., min_length=3, max_length=255, description="Ticket title")
|
||||
message: str = Field(..., min_length=10, max_length=4000, description="Initial message")
|
||||
|
||||
|
||||
class TicketMessageCreateRequest(BaseModel):
|
||||
"""Request to add message to ticket."""
|
||||
message: str = Field(..., min_length=1, max_length=4000, description="Message text")
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Cabinet services."""
|
||||
|
||||
from .email_service import EmailService, email_service
|
||||
|
||||
__all__ = ["EmailService", "email_service"]
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Email service for sending verification and password reset emails."""
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from typing import Optional
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmailService:
|
||||
"""Service for sending emails via SMTP."""
|
||||
|
||||
def __init__(self):
|
||||
self.host = settings.SMTP_HOST
|
||||
self.port = settings.SMTP_PORT
|
||||
self.user = settings.SMTP_USER
|
||||
self.password = settings.SMTP_PASSWORD
|
||||
self.from_email = settings.get_smtp_from_email()
|
||||
self.from_name = settings.SMTP_FROM_NAME
|
||||
self.use_tls = settings.SMTP_USE_TLS
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""Check if SMTP is properly configured."""
|
||||
return settings.is_smtp_configured()
|
||||
|
||||
def _get_smtp_connection(self) -> smtplib.SMTP:
|
||||
"""Create and return SMTP connection."""
|
||||
if self.use_tls:
|
||||
smtp = smtplib.SMTP(self.host, self.port)
|
||||
smtp.starttls()
|
||||
else:
|
||||
smtp = smtplib.SMTP(self.host, self.port)
|
||||
|
||||
if self.user and self.password:
|
||||
smtp.login(self.user, self.password)
|
||||
|
||||
return smtp
|
||||
|
||||
def send_email(
|
||||
self,
|
||||
to_email: str,
|
||||
subject: str,
|
||||
body_html: str,
|
||||
body_text: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Send an email.
|
||||
|
||||
Args:
|
||||
to_email: Recipient email address
|
||||
subject: Email subject
|
||||
body_html: HTML body content
|
||||
body_text: Plain text body (optional, generated from HTML if not provided)
|
||||
|
||||
Returns:
|
||||
True if email was sent successfully, False otherwise
|
||||
"""
|
||||
if not self.is_configured():
|
||||
logger.warning("SMTP is not configured, cannot send email")
|
||||
return False
|
||||
|
||||
try:
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = f"{self.from_name} <{self.from_email}>"
|
||||
msg["To"] = to_email
|
||||
|
||||
# Plain text version
|
||||
if body_text is None:
|
||||
# Simple HTML to text conversion
|
||||
import re
|
||||
body_text = re.sub(r"<[^>]+>", "", body_html)
|
||||
body_text = body_text.replace(" ", " ")
|
||||
body_text = body_text.replace("&", "&")
|
||||
body_text = body_text.replace("<", "<")
|
||||
body_text = body_text.replace(">", ">")
|
||||
|
||||
part1 = MIMEText(body_text, "plain", "utf-8")
|
||||
part2 = MIMEText(body_html, "html", "utf-8")
|
||||
|
||||
msg.attach(part1)
|
||||
msg.attach(part2)
|
||||
|
||||
with self._get_smtp_connection() as smtp:
|
||||
smtp.sendmail(self.from_email, to_email, msg.as_string())
|
||||
|
||||
logger.info(f"Email sent successfully to {to_email}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send email to {to_email}: {e}")
|
||||
return False
|
||||
|
||||
def send_verification_email(
|
||||
self,
|
||||
to_email: str,
|
||||
verification_token: str,
|
||||
verification_url: str,
|
||||
username: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Send email verification email.
|
||||
|
||||
Args:
|
||||
to_email: Recipient email address
|
||||
verification_token: Verification token
|
||||
verification_url: Base URL for verification (token will be appended)
|
||||
username: User's name for personalization
|
||||
|
||||
Returns:
|
||||
True if email was sent successfully, False otherwise
|
||||
"""
|
||||
full_url = f"{verification_url}?token={verification_token}"
|
||||
greeting = f"Hello{', ' + username if username else ''}!"
|
||||
|
||||
subject = "Verify your email address"
|
||||
body_html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
|
||||
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
|
||||
.button {{
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
background-color: #007bff;
|
||||
color: white !important;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
margin: 20px 0;
|
||||
}}
|
||||
.footer {{ margin-top: 30px; font-size: 12px; color: #666; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h2>{greeting}</h2>
|
||||
<p>Thank you for registering! Please verify your email address by clicking the button below:</p>
|
||||
<a href="{full_url}" class="button">Verify Email</a>
|
||||
<p>Or copy and paste this link in your browser:</p>
|
||||
<p><a href="{full_url}">{full_url}</a></p>
|
||||
<p>This link will expire in {settings.get_cabinet_email_verification_expire_hours()} hours.</p>
|
||||
<p>If you didn't create an account, you can safely ignore this email.</p>
|
||||
<div class="footer">
|
||||
<p>Best regards,<br>{self.from_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return self.send_email(to_email, subject, body_html)
|
||||
|
||||
def send_password_reset_email(
|
||||
self,
|
||||
to_email: str,
|
||||
reset_token: str,
|
||||
reset_url: str,
|
||||
username: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Send password reset email.
|
||||
|
||||
Args:
|
||||
to_email: Recipient email address
|
||||
reset_token: Password reset token
|
||||
reset_url: Base URL for password reset (token will be appended)
|
||||
username: User's name for personalization
|
||||
|
||||
Returns:
|
||||
True if email was sent successfully, False otherwise
|
||||
"""
|
||||
full_url = f"{reset_url}?token={reset_token}"
|
||||
greeting = f"Hello{', ' + username if username else ''}!"
|
||||
|
||||
subject = "Reset your password"
|
||||
body_html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
|
||||
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
|
||||
.button {{
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
background-color: #dc3545;
|
||||
color: white !important;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
margin: 20px 0;
|
||||
}}
|
||||
.footer {{ margin-top: 30px; font-size: 12px; color: #666; }}
|
||||
.warning {{ color: #dc3545; font-weight: bold; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h2>{greeting}</h2>
|
||||
<p>We received a request to reset your password. Click the button below to set a new password:</p>
|
||||
<a href="{full_url}" class="button">Reset Password</a>
|
||||
<p>Or copy and paste this link in your browser:</p>
|
||||
<p><a href="{full_url}">{full_url}</a></p>
|
||||
<p>This link will expire in {settings.get_cabinet_password_reset_expire_hours()} hour(s).</p>
|
||||
<p class="warning">If you didn't request a password reset, please ignore this email or contact support if you're concerned.</p>
|
||||
<div class="footer">
|
||||
<p>Best regards,<br>{self.from_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return self.send_email(to_email, subject, body_html)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
email_service = EmailService()
|
||||
+134
-8
@@ -79,7 +79,8 @@ class Settings(BaseSettings):
|
||||
DATABASE_MODE: str = "auto"
|
||||
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
|
||||
CART_TTL_SECONDS: int = 3600 # Время жизни корзины пользователя в Redis (1 час)
|
||||
|
||||
REMNAWAVE_API_URL: Optional[str] = None
|
||||
REMNAWAVE_API_KEY: Optional[str] = None
|
||||
REMNAWAVE_SECRET_KEY: Optional[str] = None
|
||||
@@ -165,7 +166,17 @@ class Settings(BaseSettings):
|
||||
TRAFFIC_SELECTION_MODE: str = "selectable"
|
||||
FIXED_TRAFFIC_LIMIT_GB: int = 100
|
||||
BUY_TRAFFIC_BUTTON_VISIBLE: bool = True
|
||||
|
||||
|
||||
# Режим продаж подписок:
|
||||
# - classic: классический режим (выбор серверов, трафика, устройств, периода отдельно)
|
||||
# - tariffs: режим тарифов (готовые пакеты с фиксированными параметрами)
|
||||
SALES_MODE: str = "classic"
|
||||
|
||||
# ID тарифа для триала в режиме тарифов (0 = использовать стандартные настройки триала)
|
||||
# Если указан ID тарифа, параметры триала берутся из тарифа (traffic_limit_gb, device_limit, allowed_squads)
|
||||
# Длительность триала всё равно берётся из TRIAL_DURATION_DAYS
|
||||
TRIAL_TARIFF_ID: int = 0
|
||||
|
||||
# Настройки докупки трафика
|
||||
TRAFFIC_TOPUP_ENABLED: bool = True # Включить/выключить функцию докупки трафика
|
||||
# Пакеты для докупки трафика (формат: "гб:цена:enabled", пустая строка = использовать TRAFFIC_PACKAGES_CONFIG)
|
||||
@@ -398,6 +409,25 @@ class Settings(BaseSettings):
|
||||
CLOUDPAYMENTS_REQUIRE_EMAIL: bool = False
|
||||
CLOUDPAYMENTS_TEST_MODE: bool = False
|
||||
|
||||
# Freekassa
|
||||
FREEKASSA_ENABLED: bool = False
|
||||
FREEKASSA_SHOP_ID: Optional[int] = None
|
||||
FREEKASSA_API_KEY: Optional[str] = None
|
||||
FREEKASSA_SECRET_WORD_1: Optional[str] = None # Для формы оплаты
|
||||
FREEKASSA_SECRET_WORD_2: Optional[str] = None # Для webhook
|
||||
FREEKASSA_DISPLAY_NAME: str = "Freekassa"
|
||||
FREEKASSA_CURRENCY: str = "RUB"
|
||||
FREEKASSA_MIN_AMOUNT_KOPEKS: int = 10000 # 100 руб
|
||||
FREEKASSA_MAX_AMOUNT_KOPEKS: int = 100000000 # 1 000 000 руб
|
||||
FREEKASSA_PAYMENT_TIMEOUT_SECONDS: int = 3600
|
||||
FREEKASSA_WEBHOOK_PATH: str = "/freekassa-webhook"
|
||||
FREEKASSA_WEBHOOK_HOST: str = "0.0.0.0"
|
||||
FREEKASSA_WEBHOOK_PORT: int = 8088
|
||||
# Способ оплаты: None = форма выбора, 42 = обычный СБП, 44 = NSPK СБП
|
||||
FREEKASSA_PAYMENT_SYSTEM_ID: Optional[int] = None
|
||||
# Использовать API для создания заказов (нужно для NSPK СБП)
|
||||
FREEKASSA_USE_API: bool = False
|
||||
|
||||
MAIN_MENU_MODE: str = "default"
|
||||
CONNECT_BUTTON_MODE: str = "guide"
|
||||
MINIAPP_CUSTOM_URL: str = ""
|
||||
@@ -493,6 +523,25 @@ class Settings(BaseSettings):
|
||||
EXTERNAL_ADMIN_TOKEN: Optional[str] = None
|
||||
EXTERNAL_ADMIN_TOKEN_BOT_ID: Optional[int] = None
|
||||
|
||||
# Cabinet (Personal Account) settings
|
||||
CABINET_ENABLED: bool = False
|
||||
CABINET_JWT_SECRET: Optional[str] = None
|
||||
CABINET_ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
|
||||
CABINET_REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
CABINET_ALLOWED_ORIGINS: str = ""
|
||||
CABINET_EMAIL_VERIFICATION_ENABLED: bool = True
|
||||
CABINET_EMAIL_VERIFICATION_EXPIRE_HOURS: int = 24
|
||||
CABINET_PASSWORD_RESET_EXPIRE_HOURS: int = 1
|
||||
|
||||
# SMTP settings for cabinet email
|
||||
SMTP_HOST: Optional[str] = None
|
||||
SMTP_PORT: int = 587
|
||||
SMTP_USER: Optional[str] = None
|
||||
SMTP_PASSWORD: Optional[str] = None
|
||||
SMTP_FROM_EMAIL: Optional[str] = None
|
||||
SMTP_FROM_NAME: str = "VPN Service"
|
||||
SMTP_USE_TLS: bool = True
|
||||
|
||||
@field_validator('MAIN_MENU_MODE', mode='before')
|
||||
@classmethod
|
||||
def normalize_main_menu_mode(cls, value: Optional[str]) -> str:
|
||||
@@ -822,6 +871,10 @@ class Settings(BaseSettings):
|
||||
|
||||
return bool(value)
|
||||
|
||||
def is_quick_amount_buttons_enabled(self) -> bool:
|
||||
"""Показывать ли кнопки быстрого выбора суммы пополнения."""
|
||||
return self.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not self.DISABLE_TOPUP_BUTTONS
|
||||
|
||||
def get_available_languages(self) -> List[str]:
|
||||
defaults = ["ru", "en", "ua", "zh"]
|
||||
|
||||
@@ -1152,6 +1205,22 @@ class Settings(BaseSettings):
|
||||
def is_modem_enabled(self) -> bool:
|
||||
return bool(self.MODEM_ENABLED)
|
||||
|
||||
def is_tariffs_mode(self) -> bool:
|
||||
"""Проверяет, включен ли режим продаж 'Тарифы'."""
|
||||
return self.SALES_MODE == "tariffs"
|
||||
|
||||
def is_classic_mode(self) -> bool:
|
||||
"""Проверяет, включен ли классический режим продаж."""
|
||||
return self.SALES_MODE != "tariffs"
|
||||
|
||||
def get_sales_mode(self) -> str:
|
||||
"""Возвращает текущий режим продаж."""
|
||||
return self.SALES_MODE if self.SALES_MODE in ("classic", "tariffs") else "classic"
|
||||
|
||||
def get_trial_tariff_id(self) -> int:
|
||||
"""Возвращает ID тарифа для триала (0 = использовать стандартные настройки)."""
|
||||
return self.TRIAL_TARIFF_ID if self.TRIAL_TARIFF_ID > 0 else 0
|
||||
|
||||
def get_modem_price_per_month(self) -> int:
|
||||
try:
|
||||
value = int(self.MODEM_PRICE_PER_MONTH)
|
||||
@@ -1209,11 +1278,12 @@ class Settings(BaseSettings):
|
||||
return applicable_discount
|
||||
|
||||
def is_trial_paid_activation_enabled(self) -> bool:
|
||||
# Если цена > 0, триал автоматически платный
|
||||
# (TRIAL_PAYMENT_ENABLED теперь опционален - для обратной совместимости)
|
||||
if self.TRIAL_ACTIVATION_PRICE > 0:
|
||||
return True
|
||||
return bool(self.TRIAL_PAYMENT_ENABLED)
|
||||
# TRIAL_PAYMENT_ENABLED - главный переключатель платной активации
|
||||
# Если выключен - триал бесплатный, независимо от цены
|
||||
if not self.TRIAL_PAYMENT_ENABLED:
|
||||
return False
|
||||
# Если включен - проверяем что цена > 0
|
||||
return self.TRIAL_ACTIVATION_PRICE > 0
|
||||
|
||||
def get_trial_activation_price(self) -> int:
|
||||
try:
|
||||
@@ -1390,6 +1460,22 @@ class Settings(BaseSettings):
|
||||
and self.CLOUDPAYMENTS_API_SECRET is not None
|
||||
)
|
||||
|
||||
def is_freekassa_enabled(self) -> bool:
|
||||
return (
|
||||
self.FREEKASSA_ENABLED
|
||||
and self.FREEKASSA_SHOP_ID is not None
|
||||
and self.FREEKASSA_API_KEY is not None
|
||||
and self.FREEKASSA_SECRET_WORD_1 is not None
|
||||
and self.FREEKASSA_SECRET_WORD_2 is not None
|
||||
)
|
||||
|
||||
def get_freekassa_display_name(self) -> str:
|
||||
name = (self.FREEKASSA_DISPLAY_NAME or "").strip()
|
||||
return name if name else "Freekassa"
|
||||
|
||||
def get_freekassa_display_name_html(self) -> str:
|
||||
return html.escape(self.get_freekassa_display_name())
|
||||
|
||||
def is_payment_verification_auto_check_enabled(self) -> bool:
|
||||
return self.PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED
|
||||
|
||||
@@ -1622,7 +1708,10 @@ class Settings(BaseSettings):
|
||||
return stars * self.get_stars_rate()
|
||||
|
||||
def rubles_to_stars(self, rubles: float) -> int:
|
||||
return max(1, math.ceil(rubles / self.get_stars_rate()))
|
||||
rate = self.get_stars_rate()
|
||||
if rate <= 0:
|
||||
raise ValueError("Stars rate must be positive")
|
||||
return max(1, math.ceil(rubles / rate))
|
||||
|
||||
def get_admin_notifications_chat_id(self) -> Optional[int]:
|
||||
if not self.ADMIN_NOTIFICATIONS_CHAT_ID:
|
||||
@@ -2005,6 +2094,43 @@ class Settings(BaseSettings):
|
||||
raw_path = "miniapp"
|
||||
return Path(raw_path)
|
||||
|
||||
# Cabinet methods
|
||||
def is_cabinet_enabled(self) -> bool:
|
||||
return bool(self.CABINET_ENABLED)
|
||||
|
||||
def get_cabinet_jwt_secret(self) -> str:
|
||||
if self.CABINET_JWT_SECRET:
|
||||
return self.CABINET_JWT_SECRET
|
||||
return self.BOT_TOKEN
|
||||
|
||||
def get_cabinet_access_token_expire_minutes(self) -> int:
|
||||
return max(1, self.CABINET_ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
def get_cabinet_refresh_token_expire_days(self) -> int:
|
||||
return max(1, self.CABINET_REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
|
||||
def get_cabinet_allowed_origins(self) -> List[str]:
|
||||
if not self.CABINET_ALLOWED_ORIGINS:
|
||||
return []
|
||||
return [o.strip() for o in self.CABINET_ALLOWED_ORIGINS.split(",") if o.strip()]
|
||||
|
||||
def is_cabinet_email_verification_enabled(self) -> bool:
|
||||
return bool(self.CABINET_EMAIL_VERIFICATION_ENABLED)
|
||||
|
||||
def get_cabinet_email_verification_expire_hours(self) -> int:
|
||||
return max(1, self.CABINET_EMAIL_VERIFICATION_EXPIRE_HOURS)
|
||||
|
||||
def get_cabinet_password_reset_expire_hours(self) -> int:
|
||||
return max(1, self.CABINET_PASSWORD_RESET_EXPIRE_HOURS)
|
||||
|
||||
def is_smtp_configured(self) -> bool:
|
||||
return bool(self.SMTP_HOST and self.SMTP_USER and self.SMTP_PASSWORD)
|
||||
|
||||
def get_smtp_from_email(self) -> Optional[str]:
|
||||
if self.SMTP_FROM_EMAIL:
|
||||
return self.SMTP_FROM_EMAIL
|
||||
return self.SMTP_USER
|
||||
|
||||
model_config = {
|
||||
"env_file": ".env",
|
||||
"env_file_encoding": "utf-8",
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""CRUD операции для платежей Freekassa."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import FreekassaPayment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_freekassa_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
order_id: str,
|
||||
amount_kopeks: int,
|
||||
currency: str = "RUB",
|
||||
description: Optional[str] = None,
|
||||
payment_url: Optional[str] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
metadata_json: Optional[str] = None,
|
||||
) -> FreekassaPayment:
|
||||
"""Создает запись о платеже Freekassa."""
|
||||
payment = FreekassaPayment(
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
expires_at=expires_at,
|
||||
metadata_json=json.loads(metadata_json) if metadata_json else None,
|
||||
status="pending",
|
||||
is_paid=False,
|
||||
)
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(f"Создан платеж Freekassa: order_id={order_id}, user_id={user_id}")
|
||||
return payment
|
||||
|
||||
|
||||
async def get_freekassa_payment_by_order_id(
|
||||
db: AsyncSession, order_id: str
|
||||
) -> Optional[FreekassaPayment]:
|
||||
"""Получает платеж по order_id."""
|
||||
result = await db.execute(
|
||||
select(FreekassaPayment).where(FreekassaPayment.order_id == order_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_freekassa_payment_by_fk_order_id(
|
||||
db: AsyncSession, freekassa_order_id: str
|
||||
) -> Optional[FreekassaPayment]:
|
||||
"""Получает платеж по ID от Freekassa (intid)."""
|
||||
result = await db.execute(
|
||||
select(FreekassaPayment).where(
|
||||
FreekassaPayment.freekassa_order_id == freekassa_order_id
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_freekassa_payment_by_id(
|
||||
db: AsyncSession, payment_id: int
|
||||
) -> Optional[FreekassaPayment]:
|
||||
"""Получает платеж по ID."""
|
||||
result = await db.execute(
|
||||
select(FreekassaPayment).where(FreekassaPayment.id == payment_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_freekassa_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: FreekassaPayment,
|
||||
*,
|
||||
status: str,
|
||||
is_paid: bool = False,
|
||||
freekassa_order_id: Optional[str] = None,
|
||||
payment_system_id: Optional[int] = None,
|
||||
callback_payload: Optional[dict] = None,
|
||||
transaction_id: Optional[int] = None,
|
||||
) -> FreekassaPayment:
|
||||
"""Обновляет статус платежа."""
|
||||
payment.status = status
|
||||
payment.is_paid = is_paid
|
||||
payment.updated_at = datetime.utcnow()
|
||||
|
||||
if is_paid:
|
||||
payment.paid_at = datetime.utcnow()
|
||||
if freekassa_order_id:
|
||||
payment.freekassa_order_id = freekassa_order_id
|
||||
if payment_system_id is not None:
|
||||
payment.payment_system_id = payment_system_id
|
||||
if callback_payload:
|
||||
payment.callback_payload = callback_payload
|
||||
if transaction_id:
|
||||
payment.transaction_id = transaction_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(
|
||||
f"Обновлен статус платежа Freekassa: order_id={payment.order_id}, "
|
||||
f"status={status}, is_paid={is_paid}"
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_pending_freekassa_payments(
|
||||
db: AsyncSession, user_id: int
|
||||
) -> List[FreekassaPayment]:
|
||||
"""Получает незавершенные платежи пользователя."""
|
||||
result = await db.execute(
|
||||
select(FreekassaPayment).where(
|
||||
FreekassaPayment.user_id == user_id,
|
||||
FreekassaPayment.status == "pending",
|
||||
FreekassaPayment.is_paid == False,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_user_freekassa_payments(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> List[FreekassaPayment]:
|
||||
"""Получает платежи пользователя с пагинацией."""
|
||||
result = await db.execute(
|
||||
select(FreekassaPayment)
|
||||
.where(FreekassaPayment.user_id == user_id)
|
||||
.order_by(FreekassaPayment.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_expired_pending_payments(
|
||||
db: AsyncSession,
|
||||
) -> List[FreekassaPayment]:
|
||||
"""Получает просроченные платежи в статусе pending."""
|
||||
now = datetime.utcnow()
|
||||
result = await db.execute(
|
||||
select(FreekassaPayment).where(
|
||||
FreekassaPayment.status == "pending",
|
||||
FreekassaPayment.is_paid == False,
|
||||
FreekassaPayment.expires_at < now,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
@@ -44,23 +44,38 @@ async def create_trial_subscription(
|
||||
duration_days: int = None,
|
||||
traffic_limit_gb: int = None,
|
||||
device_limit: Optional[int] = None,
|
||||
squad_uuid: str = None
|
||||
squad_uuid: str = None,
|
||||
connected_squads: List[str] = None,
|
||||
tariff_id: Optional[int] = None,
|
||||
) -> Subscription:
|
||||
|
||||
"""Создает триальную подписку.
|
||||
|
||||
Args:
|
||||
connected_squads: Список UUID сквадов (если указан, squad_uuid игнорируется)
|
||||
tariff_id: ID тарифа (для режима тарифов)
|
||||
"""
|
||||
duration_days = duration_days or settings.TRIAL_DURATION_DAYS
|
||||
traffic_limit_gb = traffic_limit_gb or settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
if device_limit is None:
|
||||
device_limit = settings.TRIAL_DEVICE_LIMIT
|
||||
if not squad_uuid:
|
||||
|
||||
# Если переданы connected_squads, используем их
|
||||
# Иначе используем squad_uuid или получаем случайный
|
||||
final_squads = []
|
||||
if connected_squads:
|
||||
final_squads = connected_squads
|
||||
elif squad_uuid:
|
||||
final_squads = [squad_uuid]
|
||||
else:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
squad_uuid = await get_random_trial_squad_uuid(db)
|
||||
|
||||
if squad_uuid:
|
||||
random_squad = await get_random_trial_squad_uuid(db)
|
||||
if random_squad:
|
||||
final_squads = [random_squad]
|
||||
logger.debug(
|
||||
"Выбран сквад %s для триальной подписки пользователя %s",
|
||||
squad_uuid,
|
||||
random_squad,
|
||||
user_id,
|
||||
)
|
||||
except Exception as error:
|
||||
@@ -80,40 +95,42 @@ async def create_trial_subscription(
|
||||
end_date=end_date,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=[squad_uuid] if squad_uuid else [],
|
||||
connected_squads=final_squads,
|
||||
autopay_enabled=settings.is_autopay_enabled_by_default(),
|
||||
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
|
||||
tariff_id=tariff_id,
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"🎁 Создана триальная подписка для пользователя {user_id}")
|
||||
logger.info(f"🎁 Создана триальная подписка для пользователя {user_id}" +
|
||||
(f" с тарифом {tariff_id}" if tariff_id else ""))
|
||||
|
||||
if squad_uuid:
|
||||
if final_squads:
|
||||
try:
|
||||
from app.database.crud.server_squad import (
|
||||
get_server_ids_by_uuids,
|
||||
add_user_to_servers,
|
||||
)
|
||||
|
||||
server_ids = await get_server_ids_by_uuids(db, [squad_uuid])
|
||||
server_ids = await get_server_ids_by_uuids(db, final_squads)
|
||||
if server_ids:
|
||||
await add_user_to_servers(db, server_ids)
|
||||
logger.info(
|
||||
"📈 Обновлен счетчик пользователей для триального сквада %s",
|
||||
squad_uuid,
|
||||
"📈 Обновлен счетчик пользователей для триальных сквадов %s",
|
||||
final_squads,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"⚠️ Не удалось найти серверы для обновления счетчика (сквад %s)",
|
||||
squad_uuid,
|
||||
"⚠️ Не удалось найти серверы для обновления счетчика (сквады %s)",
|
||||
final_squads,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"⚠️ Ошибка обновления счетчика пользователей для триального сквада %s: %s",
|
||||
squad_uuid,
|
||||
"⚠️ Ошибка обновления счетчика пользователей для триальных сквадов %s: %s",
|
||||
final_squads,
|
||||
error,
|
||||
)
|
||||
|
||||
@@ -129,6 +146,7 @@ async def create_paid_subscription(
|
||||
connected_squads: List[str] = None,
|
||||
update_server_counters: bool = False,
|
||||
is_trial: bool = False,
|
||||
tariff_id: Optional[int] = None,
|
||||
) -> Subscription:
|
||||
|
||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||
@@ -147,6 +165,7 @@ async def create_paid_subscription(
|
||||
connected_squads=connected_squads or [],
|
||||
autopay_enabled=settings.is_autopay_enabled_by_default(),
|
||||
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
|
||||
tariff_id=tariff_id,
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
@@ -276,8 +295,24 @@ async def replace_subscription(
|
||||
async def extend_subscription(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
days: int
|
||||
days: int,
|
||||
*,
|
||||
tariff_id: Optional[int] = None,
|
||||
traffic_limit_gb: Optional[int] = None,
|
||||
device_limit: Optional[int] = None,
|
||||
connected_squads: Optional[List[str]] = None,
|
||||
) -> Subscription:
|
||||
"""Продлевает подписку на указанное количество дней.
|
||||
|
||||
Args:
|
||||
db: Сессия базы данных
|
||||
subscription: Подписка для продления
|
||||
days: Количество дней для продления
|
||||
tariff_id: ID тарифа (опционально, для режима тарифов)
|
||||
traffic_limit_gb: Лимит трафика ГБ (опционально, для режима тарифов)
|
||||
device_limit: Лимит устройств (опционально, для режима тарифов)
|
||||
connected_squads: Список UUID сквадов (опционально, для режима тарифов)
|
||||
"""
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
logger.info(f"🔄 Продление подписки {subscription.id} на {days} дней")
|
||||
@@ -320,7 +355,7 @@ async def extend_subscription(
|
||||
|
||||
# Логируем статус подписки перед проверкой
|
||||
logger.info(f"🔄 Продление подписки {subscription.id}, текущий статус: {subscription.status}, дни: {days}")
|
||||
|
||||
|
||||
if days > 0 and subscription.status in (
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
@@ -339,13 +374,37 @@ async def extend_subscription(
|
||||
days
|
||||
)
|
||||
|
||||
if settings.RESET_TRAFFIC_ON_PAYMENT:
|
||||
# Обновляем параметры тарифа, если переданы
|
||||
if tariff_id is not None:
|
||||
old_tariff_id = subscription.tariff_id
|
||||
subscription.tariff_id = tariff_id
|
||||
logger.info(f"📦 Обновлен тариф подписки: {old_tariff_id} → {tariff_id}")
|
||||
|
||||
if traffic_limit_gb is not None:
|
||||
old_traffic = subscription.traffic_limit_gb
|
||||
subscription.traffic_limit_gb = traffic_limit_gb
|
||||
subscription.traffic_used_gb = 0.0
|
||||
subscription.purchased_traffic_gb = 0 # Сбрасываем докупленный трафик вместе с использованным
|
||||
subscription.purchased_traffic_gb = 0
|
||||
logger.info(f"📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb} ГБ")
|
||||
elif settings.RESET_TRAFFIC_ON_PAYMENT:
|
||||
subscription.traffic_used_gb = 0.0
|
||||
subscription.purchased_traffic_gb = 0
|
||||
logger.info("🔄 Сбрасываем использованный и докупленный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT")
|
||||
|
||||
if device_limit is not None:
|
||||
old_devices = subscription.device_limit
|
||||
subscription.device_limit = device_limit
|
||||
logger.info(f"📱 Обновлен лимит устройств: {old_devices} → {device_limit}")
|
||||
|
||||
if connected_squads is not None:
|
||||
old_squads = subscription.connected_squads
|
||||
subscription.connected_squads = connected_squads
|
||||
logger.info(f"🌍 Обновлены сквады: {old_squads} → {connected_squads}")
|
||||
|
||||
# В режиме fixed_with_topup при продлении сбрасываем трафик до фиксированного лимита
|
||||
if settings.is_traffic_fixed() and days > 0:
|
||||
# Только если не передан traffic_limit_gb И у подписки нет тарифа (классический режим)
|
||||
# Если у подписки есть tariff_id - трафик определяется тарифом, не сбрасываем
|
||||
if traffic_limit_gb is None and settings.is_traffic_fixed() and days > 0 and subscription.tariff_id is None:
|
||||
fixed_limit = settings.get_fixed_traffic_limit()
|
||||
old_limit = subscription.traffic_limit_gb
|
||||
if subscription.traffic_limit_gb != fixed_limit or (subscription.purchased_traffic_gb or 0) > 0:
|
||||
@@ -527,6 +586,33 @@ async def deactivate_subscription(
|
||||
return subscription
|
||||
|
||||
|
||||
async def reactivate_subscription(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription
|
||||
) -> Subscription:
|
||||
"""Реактивация подписки (например, после повторной подписки на канал).
|
||||
|
||||
Активирует только если подписка была DISABLED и ещё не истекла.
|
||||
Не логирует если реактивация не требуется.
|
||||
"""
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Тихо выходим если реактивация не нужна
|
||||
if subscription.status != SubscriptionStatus.DISABLED.value:
|
||||
return subscription
|
||||
|
||||
if subscription.end_date and subscription.end_date <= now:
|
||||
return subscription
|
||||
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.updated_at = now
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
async def get_expiring_subscriptions(
|
||||
db: AsyncSession,
|
||||
days_before: int = 3
|
||||
@@ -1429,8 +1515,11 @@ async def create_subscription_no_commit(
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
|
||||
# Выполняем flush, чтобы получить присвоенный первичный ключ
|
||||
await db.flush()
|
||||
|
||||
# Не коммитим сразу, оставляем для пакетной обработки
|
||||
|
||||
logger.info(f"✅ Подготовлена подписка для пользователя {user_id} (ожидает коммита)")
|
||||
return subscription
|
||||
|
||||
@@ -1498,10 +1587,15 @@ async def create_pending_subscription(
|
||||
device_limit: int = 1,
|
||||
connected_squads: List[str] = None,
|
||||
payment_method: str = "pending",
|
||||
total_price_kopeks: int = 0
|
||||
total_price_kopeks: int = 0,
|
||||
is_trial: bool = False,
|
||||
) -> Subscription:
|
||||
"""Creates a pending subscription that will be activated after payment."""
|
||||
|
||||
"""Creates a pending subscription that will be activated after payment.
|
||||
|
||||
Args:
|
||||
is_trial: If True, marks the subscription as a trial subscription.
|
||||
"""
|
||||
trial_label = "триальная " if is_trial else ""
|
||||
current_time = datetime.utcnow()
|
||||
end_date = current_time + timedelta(days=duration_days)
|
||||
|
||||
@@ -1513,13 +1607,14 @@ async def create_pending_subscription(
|
||||
and existing_subscription.end_date > current_time
|
||||
):
|
||||
logger.warning(
|
||||
"⚠️ Попытка создать pending подписку для активного пользователя %s. Возвращаем существующую запись.",
|
||||
"⚠️ Попытка создать pending %sподписку для активного пользователя %s. Возвращаем существующую запись.",
|
||||
trial_label,
|
||||
user_id,
|
||||
)
|
||||
return existing_subscription
|
||||
|
||||
existing_subscription.status = SubscriptionStatus.PENDING.value
|
||||
existing_subscription.is_trial = False
|
||||
existing_subscription.is_trial = is_trial
|
||||
existing_subscription.start_date = current_time
|
||||
existing_subscription.end_date = end_date
|
||||
existing_subscription.traffic_limit_gb = traffic_limit_gb
|
||||
@@ -1532,7 +1627,8 @@ async def create_pending_subscription(
|
||||
await db.refresh(existing_subscription)
|
||||
|
||||
logger.info(
|
||||
"♻️ Обновлена ожидающая подписка пользователя %s, ID: %s, метод оплаты: %s",
|
||||
"♻️ Обновлена ожидающая %sподписка пользователя %s, ID: %s, метод оплаты: %s",
|
||||
trial_label,
|
||||
user_id,
|
||||
existing_subscription.id,
|
||||
payment_method,
|
||||
@@ -1542,7 +1638,7 @@ async def create_pending_subscription(
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.PENDING.value,
|
||||
is_trial=False,
|
||||
is_trial=is_trial,
|
||||
start_date=current_time,
|
||||
end_date=end_date,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
@@ -1551,13 +1647,14 @@ async def create_pending_subscription(
|
||||
autopay_enabled=settings.is_autopay_enabled_by_default(),
|
||||
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
|
||||
)
|
||||
|
||||
|
||||
db.add(subscription)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
|
||||
logger.info(
|
||||
"💳 Создана ожидающая подписка для пользователя %s, ID: %s, метод оплаты: %s",
|
||||
"💳 Создана ожидающая %sподписка для пользователя %s, ID: %s, метод оплаты: %s",
|
||||
trial_label,
|
||||
user_id,
|
||||
subscription.id,
|
||||
payment_method,
|
||||
@@ -1566,6 +1663,7 @@ async def create_pending_subscription(
|
||||
return subscription
|
||||
|
||||
|
||||
# Обратная совместимость: алиас для триальной подписки
|
||||
async def create_pending_trial_subscription(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
@@ -1574,74 +1672,21 @@ async def create_pending_trial_subscription(
|
||||
device_limit: int = 1,
|
||||
connected_squads: List[str] = None,
|
||||
payment_method: str = "pending",
|
||||
total_price_kopeks: int = 0
|
||||
total_price_kopeks: int = 0,
|
||||
) -> Subscription:
|
||||
"""Creates a pending trial subscription that will be activated after payment."""
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
end_date = current_time + timedelta(days=duration_days)
|
||||
|
||||
existing_subscription = await get_subscription_by_user_id(db, user_id)
|
||||
|
||||
if existing_subscription:
|
||||
if (
|
||||
existing_subscription.status == SubscriptionStatus.ACTIVE.value
|
||||
and existing_subscription.end_date > current_time
|
||||
):
|
||||
logger.warning(
|
||||
"⚠️ Попытка создать pending триал для активного пользователя %s. Возвращаем существующую запись.",
|
||||
user_id,
|
||||
)
|
||||
return existing_subscription
|
||||
|
||||
# Обновляем существующую подписку
|
||||
existing_subscription.status = SubscriptionStatus.PENDING.value
|
||||
existing_subscription.is_trial = True # Помечаем как триальную
|
||||
existing_subscription.start_date = current_time
|
||||
existing_subscription.end_date = end_date
|
||||
existing_subscription.traffic_limit_gb = traffic_limit_gb
|
||||
existing_subscription.device_limit = device_limit
|
||||
existing_subscription.connected_squads = connected_squads or []
|
||||
existing_subscription.traffic_used_gb = 0.0
|
||||
existing_subscription.updated_at = current_time
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_subscription)
|
||||
|
||||
logger.info(
|
||||
"♻️ Обновлена ожидающая триальная подписка пользователя %s, ID: %s, метод оплаты: %s",
|
||||
user_id,
|
||||
existing_subscription.id,
|
||||
payment_method,
|
||||
)
|
||||
return existing_subscription
|
||||
|
||||
subscription = Subscription(
|
||||
"""Creates a pending trial subscription. Wrapper for create_pending_subscription with is_trial=True."""
|
||||
return await create_pending_subscription(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.PENDING.value,
|
||||
is_trial=True, # Помечаем как триальную
|
||||
start_date=current_time,
|
||||
end_date=end_date,
|
||||
duration_days=duration_days,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads or [],
|
||||
autopay_enabled=settings.is_autopay_enabled_by_default(),
|
||||
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
|
||||
connected_squads=connected_squads,
|
||||
payment_method=payment_method,
|
||||
total_price_kopeks=total_price_kopeks,
|
||||
is_trial=True,
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(
|
||||
"💳 Создана ожидающая триальная подписка для пользователя %s, ID: %s, метод оплаты: %s",
|
||||
user_id,
|
||||
subscription.id,
|
||||
payment_method,
|
||||
)
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
async def activate_pending_subscription(
|
||||
db: AsyncSession,
|
||||
@@ -1649,8 +1694,6 @@ async def activate_pending_subscription(
|
||||
period_days: int = None
|
||||
) -> Optional[Subscription]:
|
||||
"""Активирует pending подписку пользователя, меняя её статус на ACTIVE."""
|
||||
from sqlalchemy import and_
|
||||
|
||||
logger.info(f"Активация pending подписки: пользователь {user_id}, период {period_days} дней")
|
||||
|
||||
# Находим pending подписку пользователя
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from sqlalchemy import func, select, update, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import Tariff, Subscription, PromoGroup, tariff_promo_groups
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_period_prices(period_prices: Optional[Dict[int, int]]) -> Dict[str, int]:
|
||||
"""Нормализует цены периодов в формат {str: int}."""
|
||||
if not period_prices:
|
||||
return {}
|
||||
|
||||
normalized: Dict[str, int] = {}
|
||||
|
||||
for key, value in period_prices.items():
|
||||
try:
|
||||
period = int(key)
|
||||
price = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
if period > 0 and price >= 0:
|
||||
normalized[str(period)] = price
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
async def get_all_tariffs(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
include_inactive: bool = False,
|
||||
offset: int = 0,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[Tariff]:
|
||||
"""Получает все тарифы с опциональной фильтрацией по активности."""
|
||||
query = select(Tariff).options(selectinload(Tariff.allowed_promo_groups))
|
||||
|
||||
if not include_inactive:
|
||||
query = query.where(Tariff.is_active.is_(True))
|
||||
|
||||
query = query.order_by(Tariff.display_order, Tariff.id)
|
||||
|
||||
if offset:
|
||||
query = query.offset(offset)
|
||||
if limit is not None:
|
||||
query = query.limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_tariff_by_id(
|
||||
db: AsyncSession,
|
||||
tariff_id: int,
|
||||
*,
|
||||
with_promo_groups: bool = True,
|
||||
) -> Optional[Tariff]:
|
||||
"""Получает тариф по ID."""
|
||||
query = select(Tariff).where(Tariff.id == tariff_id)
|
||||
|
||||
if with_promo_groups:
|
||||
query = query.options(selectinload(Tariff.allowed_promo_groups))
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def count_tariffs(db: AsyncSession, *, include_inactive: bool = False) -> int:
|
||||
"""Подсчитывает количество тарифов."""
|
||||
query = select(func.count(Tariff.id))
|
||||
|
||||
if not include_inactive:
|
||||
query = query.where(Tariff.is_active.is_(True))
|
||||
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
async def get_trial_tariff(db: AsyncSession) -> Optional[Tariff]:
|
||||
"""Получает тариф, доступный для триала (is_trial_available=True)."""
|
||||
query = (
|
||||
select(Tariff)
|
||||
.where(Tariff.is_trial_available.is_(True))
|
||||
.where(Tariff.is_active.is_(True))
|
||||
.options(selectinload(Tariff.allowed_promo_groups))
|
||||
.limit(1)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def set_trial_tariff(db: AsyncSession, tariff_id: int) -> Optional[Tariff]:
|
||||
"""Устанавливает тариф как триальный (снимает флаг с других тарифов)."""
|
||||
# Снимаем флаг с всех тарифов
|
||||
await db.execute(
|
||||
Tariff.__table__.update().values(is_trial_available=False)
|
||||
)
|
||||
|
||||
# Устанавливаем флаг на выбранный тариф
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
if tariff:
|
||||
tariff.is_trial_available = True
|
||||
await db.commit()
|
||||
await db.refresh(tariff)
|
||||
|
||||
return tariff
|
||||
|
||||
|
||||
async def clear_trial_tariff(db: AsyncSession) -> None:
|
||||
"""Снимает флаг триала со всех тарифов."""
|
||||
await db.execute(
|
||||
Tariff.__table__.update().values(is_trial_available=False)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_tariffs_for_user(
|
||||
db: AsyncSession,
|
||||
promo_group_id: Optional[int] = None,
|
||||
) -> List[Tariff]:
|
||||
"""
|
||||
Получает тарифы, доступные для пользователя с учетом его промогруппы.
|
||||
Если у тарифа нет ограничений по промогруппам - он доступен всем.
|
||||
"""
|
||||
query = (
|
||||
select(Tariff)
|
||||
.options(selectinload(Tariff.allowed_promo_groups))
|
||||
.where(Tariff.is_active.is_(True))
|
||||
.order_by(Tariff.display_order, Tariff.id)
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
tariffs = result.scalars().all()
|
||||
|
||||
# Фильтруем по промогруппе
|
||||
available_tariffs = []
|
||||
for tariff in tariffs:
|
||||
if not tariff.allowed_promo_groups:
|
||||
# Нет ограничений - доступен всем
|
||||
available_tariffs.append(tariff)
|
||||
elif promo_group_id is not None:
|
||||
# Проверяем, есть ли промогруппа пользователя в списке разрешенных
|
||||
if any(pg.id == promo_group_id for pg in tariff.allowed_promo_groups):
|
||||
available_tariffs.append(tariff)
|
||||
# else: пользователь без промогруппы, а у тарифа есть ограничения - пропускаем
|
||||
|
||||
return available_tariffs
|
||||
|
||||
|
||||
async def create_tariff(
|
||||
db: AsyncSession,
|
||||
name: str,
|
||||
*,
|
||||
description: Optional[str] = None,
|
||||
display_order: int = 0,
|
||||
is_active: bool = True,
|
||||
traffic_limit_gb: int = 100,
|
||||
device_limit: int = 1,
|
||||
allowed_squads: Optional[List[str]] = None,
|
||||
period_prices: Optional[Dict[int, int]] = None,
|
||||
tier_level: int = 1,
|
||||
is_trial_available: bool = False,
|
||||
promo_group_ids: Optional[List[int]] = None,
|
||||
) -> Tariff:
|
||||
"""Создает новый тариф."""
|
||||
normalized_prices = _normalize_period_prices(period_prices)
|
||||
|
||||
tariff = Tariff(
|
||||
name=name.strip(),
|
||||
description=description.strip() if description else None,
|
||||
display_order=max(0, display_order),
|
||||
is_active=is_active,
|
||||
traffic_limit_gb=max(0, traffic_limit_gb),
|
||||
device_limit=max(1, device_limit),
|
||||
allowed_squads=allowed_squads or [],
|
||||
period_prices=normalized_prices,
|
||||
tier_level=max(1, tier_level),
|
||||
is_trial_available=is_trial_available,
|
||||
)
|
||||
|
||||
db.add(tariff)
|
||||
await db.flush()
|
||||
|
||||
# Добавляем промогруппы если указаны
|
||||
if promo_group_ids:
|
||||
promo_groups_result = await db.execute(
|
||||
select(PromoGroup).where(PromoGroup.id.in_(promo_group_ids))
|
||||
)
|
||||
promo_groups = promo_groups_result.scalars().all()
|
||||
tariff.allowed_promo_groups = list(promo_groups)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(tariff)
|
||||
|
||||
logger.info(
|
||||
"Создан тариф '%s' (id=%s, tier=%s, traffic=%sGB, devices=%s, prices=%s)",
|
||||
tariff.name,
|
||||
tariff.id,
|
||||
tariff.tier_level,
|
||||
tariff.traffic_limit_gb,
|
||||
tariff.device_limit,
|
||||
normalized_prices,
|
||||
)
|
||||
|
||||
return tariff
|
||||
|
||||
|
||||
async def update_tariff(
|
||||
db: AsyncSession,
|
||||
tariff: Tariff,
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
display_order: Optional[int] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
traffic_limit_gb: Optional[int] = None,
|
||||
device_limit: Optional[int] = None,
|
||||
device_price_kopeks: Optional[int] = ..., # ... = не передан, None = сбросить
|
||||
allowed_squads: Optional[List[str]] = None,
|
||||
period_prices: Optional[Dict[int, int]] = None,
|
||||
tier_level: Optional[int] = None,
|
||||
is_trial_available: Optional[bool] = None,
|
||||
promo_group_ids: Optional[List[int]] = None,
|
||||
) -> Tariff:
|
||||
"""Обновляет существующий тариф."""
|
||||
if name is not None:
|
||||
tariff.name = name.strip()
|
||||
if description is not None:
|
||||
tariff.description = description.strip() if description else None
|
||||
if display_order is not None:
|
||||
tariff.display_order = max(0, display_order)
|
||||
if is_active is not None:
|
||||
tariff.is_active = is_active
|
||||
if traffic_limit_gb is not None:
|
||||
tariff.traffic_limit_gb = max(0, traffic_limit_gb)
|
||||
if device_limit is not None:
|
||||
tariff.device_limit = max(1, device_limit)
|
||||
if device_price_kopeks is not ...:
|
||||
# Если передан device_price_kopeks (включая None) - обновляем
|
||||
tariff.device_price_kopeks = device_price_kopeks
|
||||
if allowed_squads is not None:
|
||||
tariff.allowed_squads = allowed_squads
|
||||
if period_prices is not None:
|
||||
tariff.period_prices = _normalize_period_prices(period_prices)
|
||||
if tier_level is not None:
|
||||
tariff.tier_level = max(1, tier_level)
|
||||
if is_trial_available is not None:
|
||||
tariff.is_trial_available = is_trial_available
|
||||
|
||||
# Обновляем промогруппы если указаны
|
||||
if promo_group_ids is not None:
|
||||
if promo_group_ids:
|
||||
promo_groups_result = await db.execute(
|
||||
select(PromoGroup).where(PromoGroup.id.in_(promo_group_ids))
|
||||
)
|
||||
promo_groups = promo_groups_result.scalars().all()
|
||||
tariff.allowed_promo_groups = list(promo_groups)
|
||||
else:
|
||||
tariff.allowed_promo_groups = []
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(tariff)
|
||||
|
||||
logger.info(
|
||||
"Обновлен тариф '%s' (id=%s)",
|
||||
tariff.name,
|
||||
tariff.id,
|
||||
)
|
||||
|
||||
return tariff
|
||||
|
||||
|
||||
async def delete_tariff(db: AsyncSession, tariff: Tariff) -> bool:
|
||||
"""
|
||||
Удаляет тариф.
|
||||
Подписки с этим тарифом получат tariff_id = NULL.
|
||||
"""
|
||||
tariff_id = tariff.id
|
||||
tariff_name = tariff.name
|
||||
|
||||
# Подсчитываем подписки с этим тарифом
|
||||
subscriptions_count = await db.execute(
|
||||
select(func.count(Subscription.id)).where(Subscription.tariff_id == tariff_id)
|
||||
)
|
||||
affected_subscriptions = subscriptions_count.scalar_one()
|
||||
|
||||
# Удаляем тариф (FK с ondelete=SET NULL автоматически обнулит tariff_id в подписках)
|
||||
await db.delete(tariff)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"Удален тариф '%s' (id=%s), затронуто подписок: %s",
|
||||
tariff_name,
|
||||
tariff_id,
|
||||
affected_subscriptions,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def get_tariff_subscriptions_count(db: AsyncSession, tariff_id: int) -> int:
|
||||
"""Подсчитывает количество подписок на тарифе."""
|
||||
result = await db.execute(
|
||||
select(func.count(Subscription.id)).where(Subscription.tariff_id == tariff_id)
|
||||
)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
async def set_tariff_promo_groups(
|
||||
db: AsyncSession,
|
||||
tariff: Tariff,
|
||||
promo_group_ids: List[int],
|
||||
) -> Tariff:
|
||||
"""Устанавливает промогруппы для тарифа."""
|
||||
if promo_group_ids:
|
||||
promo_groups_result = await db.execute(
|
||||
select(PromoGroup).where(PromoGroup.id.in_(promo_group_ids))
|
||||
)
|
||||
promo_groups = promo_groups_result.scalars().all()
|
||||
tariff.allowed_promo_groups = list(promo_groups)
|
||||
else:
|
||||
tariff.allowed_promo_groups = []
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(tariff)
|
||||
|
||||
return tariff
|
||||
|
||||
|
||||
async def add_promo_group_to_tariff(
|
||||
db: AsyncSession,
|
||||
tariff: Tariff,
|
||||
promo_group_id: int,
|
||||
) -> bool:
|
||||
"""Добавляет промогруппу к тарифу."""
|
||||
promo_group = await db.get(PromoGroup, promo_group_id)
|
||||
if not promo_group:
|
||||
return False
|
||||
|
||||
if promo_group not in tariff.allowed_promo_groups:
|
||||
tariff.allowed_promo_groups.append(promo_group)
|
||||
await db.commit()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def remove_promo_group_from_tariff(
|
||||
db: AsyncSession,
|
||||
tariff: Tariff,
|
||||
promo_group_id: int,
|
||||
) -> bool:
|
||||
"""Удаляет промогруппу из тарифа."""
|
||||
for pg in tariff.allowed_promo_groups:
|
||||
if pg.id == promo_group_id:
|
||||
tariff.allowed_promo_groups.remove(pg)
|
||||
await db.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def get_tariffs_with_subscriptions_count(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
include_inactive: bool = False,
|
||||
) -> List[tuple]:
|
||||
"""Получает тарифы с количеством подписок."""
|
||||
query = (
|
||||
select(Tariff, func.count(Subscription.id))
|
||||
.outerjoin(Subscription, Subscription.tariff_id == Tariff.id)
|
||||
.group_by(Tariff.id)
|
||||
.order_by(Tariff.display_order, Tariff.id)
|
||||
)
|
||||
|
||||
if not include_inactive:
|
||||
query = query.where(Tariff.is_active.is_(True))
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.all()
|
||||
|
||||
|
||||
async def reorder_tariffs(
|
||||
db: AsyncSession,
|
||||
tariff_order: List[int],
|
||||
) -> None:
|
||||
"""Изменяет порядок отображения тарифов."""
|
||||
for order, tariff_id in enumerate(tariff_order):
|
||||
await db.execute(
|
||||
update(Tariff)
|
||||
.where(Tariff.id == tariff_id)
|
||||
.values(display_order=order)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info("Изменен порядок тарифов: %s", tariff_order)
|
||||
@@ -1,4 +1,5 @@
|
||||
from typing import List, Optional
|
||||
import logging
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc, and_, or_, update, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -6,6 +7,8 @@ from datetime import datetime
|
||||
|
||||
from app.database.models import Ticket, TicketMessage, TicketStatus, User, SupportAuditLog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TicketCRUD:
|
||||
"""CRUD операции для работы с тикетами"""
|
||||
@@ -47,6 +50,25 @@ class TicketCRUD:
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(ticket)
|
||||
|
||||
# Отправляем событие о создании тикета
|
||||
try:
|
||||
from app.services.event_emitter import event_emitter
|
||||
await event_emitter.emit(
|
||||
"ticket.created",
|
||||
{
|
||||
"ticket_id": ticket.id,
|
||||
"user_id": user_id,
|
||||
"title": title,
|
||||
"status": ticket.status,
|
||||
"priority": priority,
|
||||
"has_media": bool(media_type and media_file_id),
|
||||
},
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning("Failed to emit ticket.created event: %s", error)
|
||||
|
||||
return ticket
|
||||
|
||||
@staticmethod
|
||||
@@ -246,6 +268,24 @@ class TicketCRUD:
|
||||
ticket.closed_at = closed_at
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Отправляем событие об изменении статуса тикета
|
||||
try:
|
||||
from app.services.event_emitter import event_emitter
|
||||
await event_emitter.emit(
|
||||
"ticket.status_changed",
|
||||
{
|
||||
"ticket_id": ticket_id,
|
||||
"user_id": ticket.user_id,
|
||||
"old_status": ticket.status, # На самом деле это уже новый статус, но для простоты оставим так
|
||||
"new_status": status,
|
||||
"closed_at": closed_at.isoformat() if closed_at else None,
|
||||
},
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning("Failed to emit ticket.status_changed event: %s", error)
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
@@ -434,6 +474,26 @@ class TicketMessageCRUD:
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
|
||||
# Отправляем событие о новом сообщении в тикете
|
||||
try:
|
||||
from app.services.event_emitter import event_emitter
|
||||
await event_emitter.emit(
|
||||
"ticket.message_added",
|
||||
{
|
||||
"ticket_id": ticket_id,
|
||||
"message_id": message.id,
|
||||
"user_id": user_id,
|
||||
"is_from_admin": is_from_admin,
|
||||
"message_text": message_text[:200], # Ограничиваем длину для события
|
||||
"has_media": bool(media_type and media_file_id),
|
||||
"status": ticket.status if ticket else None,
|
||||
},
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning("Failed to emit ticket.message_added event: %s", error)
|
||||
|
||||
return message
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -38,6 +38,27 @@ async def create_transaction(
|
||||
|
||||
logger.info(f"💳 Создана транзакция: {type.value} на {amount_kopeks/100}₽ для пользователя {user_id}")
|
||||
|
||||
# Отправляем событие о транзакции
|
||||
try:
|
||||
from app.services.event_emitter import event_emitter
|
||||
await event_emitter.emit(
|
||||
"payment.completed" if type == TransactionType.DEPOSIT else "transaction.created",
|
||||
{
|
||||
"transaction_id": transaction.id,
|
||||
"user_id": user_id,
|
||||
"type": type.value,
|
||||
"amount_kopeks": amount_kopeks,
|
||||
"amount_rubles": amount_kopeks / 100,
|
||||
"payment_method": payment_method.value if payment_method else None,
|
||||
"external_id": external_id,
|
||||
"is_completed": is_completed,
|
||||
"description": description,
|
||||
},
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning("Failed to emit transaction event: %s", error)
|
||||
|
||||
try:
|
||||
from app.services.promo_group_assignment import (
|
||||
maybe_assign_promo_group_by_total_spent,
|
||||
|
||||
+101
-54
@@ -28,6 +28,48 @@ from app.utils.validators import sanitize_telegram_name
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_spending_stats_select():
|
||||
"""
|
||||
Возвращает базовый SELECT для статистики трат пользователей.
|
||||
|
||||
Используется в:
|
||||
- get_users_list() для сортировки по тратам/покупкам
|
||||
- get_users_spending_stats() для получения статистики
|
||||
|
||||
Returns:
|
||||
Tuple колонок (user_id, total_spent, purchase_count)
|
||||
"""
|
||||
from app.database.models import Transaction
|
||||
|
||||
return (
|
||||
Transaction.user_id.label("user_id"),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.amount_kopeks,
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("total_spent"),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
1,
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("purchase_count"),
|
||||
)
|
||||
|
||||
|
||||
def generate_referral_code() -> str:
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
code_suffix = ''.join(secrets.choice(alphabet) for _ in range(8))
|
||||
@@ -279,6 +321,26 @@ async def create_user(
|
||||
logger.info(
|
||||
f"✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}"
|
||||
)
|
||||
|
||||
# Отправляем событие о создании пользователя
|
||||
try:
|
||||
from app.services.event_emitter import event_emitter
|
||||
await event_emitter.emit(
|
||||
"user.created",
|
||||
{
|
||||
"user_id": user.id,
|
||||
"telegram_id": user.telegram_id,
|
||||
"username": user.username,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"referral_code": user.referral_code,
|
||||
"referred_by_id": user.referred_by_id,
|
||||
},
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning("Failed to emit user.created event: %s", error)
|
||||
|
||||
return user
|
||||
|
||||
except IntegrityError as exc:
|
||||
@@ -661,33 +723,7 @@ async def get_users_list(
|
||||
from app.database.models import Transaction
|
||||
|
||||
transactions_stats = (
|
||||
select(
|
||||
Transaction.user_id.label("user_id"),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.amount_kopeks,
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("total_spent"),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
1,
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("purchase_count"),
|
||||
)
|
||||
select(*_build_spending_stats_select())
|
||||
.where(Transaction.is_completed.is_(True))
|
||||
.group_by(Transaction.user_id)
|
||||
.subquery()
|
||||
@@ -764,39 +800,23 @@ async def get_users_spending_stats(
|
||||
db: AsyncSession,
|
||||
user_ids: List[int]
|
||||
) -> Dict[int, Dict[str, int]]:
|
||||
"""
|
||||
Получает статистику трат для списка пользователей.
|
||||
|
||||
Args:
|
||||
db: Сессия базы данных
|
||||
user_ids: Список ID пользователей
|
||||
|
||||
Returns:
|
||||
Словарь {user_id: {"total_spent": int, "purchase_count": int}}
|
||||
"""
|
||||
if not user_ids:
|
||||
return {}
|
||||
|
||||
from app.database.models import Transaction
|
||||
|
||||
stats_query = (
|
||||
select(
|
||||
Transaction.user_id,
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.amount_kopeks,
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("total_spent"),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
1,
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("purchase_count"),
|
||||
)
|
||||
select(*_build_spending_stats_select())
|
||||
.where(
|
||||
Transaction.user_id.in_(user_ids),
|
||||
Transaction.is_completed.is_(True),
|
||||
@@ -993,3 +1013,30 @@ async def get_users_statistics(db: AsyncSession) -> dict:
|
||||
"new_week": new_week,
|
||||
"new_month": new_month
|
||||
}
|
||||
|
||||
|
||||
async def get_users_with_active_subscriptions(db: AsyncSession) -> List[User]:
|
||||
"""
|
||||
Получает список пользователей с активными подписками.
|
||||
Используется для мониторинга трафика.
|
||||
|
||||
Returns:
|
||||
Список пользователей с активными подписками и remnawave_uuid
|
||||
"""
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.join(Subscription, User.id == Subscription.user_id)
|
||||
.where(
|
||||
and_(
|
||||
User.remnawave_uuid.isnot(None),
|
||||
User.status == UserStatus.ACTIVE.value,
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.end_date > current_time,
|
||||
)
|
||||
)
|
||||
.options(selectinload(User.subscription))
|
||||
)
|
||||
|
||||
return result.scalars().unique().all()
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import Webhook, WebhookDelivery
|
||||
|
||||
|
||||
async def create_webhook(
|
||||
db: AsyncSession,
|
||||
name: str,
|
||||
url: str,
|
||||
event_type: str,
|
||||
secret: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
) -> Webhook:
|
||||
"""Создать новый webhook."""
|
||||
webhook = Webhook(
|
||||
name=name,
|
||||
url=url,
|
||||
event_type=event_type,
|
||||
secret=secret,
|
||||
description=description,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(webhook)
|
||||
await db.commit()
|
||||
await db.refresh(webhook)
|
||||
return webhook
|
||||
|
||||
|
||||
async def get_webhook_by_id(db: AsyncSession, webhook_id: int) -> Optional[Webhook]:
|
||||
"""Получить webhook по ID."""
|
||||
result = await db.execute(select(Webhook).where(Webhook.id == webhook_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_webhooks(
|
||||
db: AsyncSession,
|
||||
event_type: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[Webhook], int]:
|
||||
"""Список webhooks с фильтрами."""
|
||||
query = select(Webhook)
|
||||
|
||||
if event_type:
|
||||
query = query.where(Webhook.event_type == event_type)
|
||||
if is_active is not None:
|
||||
query = query.where(Webhook.is_active == is_active)
|
||||
|
||||
# Подсчет общего количества
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total = await db.scalar(count_query) or 0
|
||||
|
||||
# Получение данных
|
||||
query = query.order_by(Webhook.created_at.desc()).offset(offset).limit(limit)
|
||||
result = await db.execute(query)
|
||||
webhooks = result.scalars().all()
|
||||
|
||||
return list(webhooks), int(total)
|
||||
|
||||
|
||||
async def get_active_webhooks_for_event(
|
||||
db: AsyncSession,
|
||||
event_type: str,
|
||||
) -> list[Webhook]:
|
||||
"""Получить все активные webhooks для конкретного события."""
|
||||
result = await db.execute(
|
||||
select(Webhook)
|
||||
.where(Webhook.event_type == event_type)
|
||||
.where(Webhook.is_active == True)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def update_webhook(
|
||||
db: AsyncSession,
|
||||
webhook: Webhook,
|
||||
name: Optional[str] = None,
|
||||
url: Optional[str] = None,
|
||||
secret: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> Webhook:
|
||||
"""Обновить webhook."""
|
||||
if name is not None:
|
||||
webhook.name = name
|
||||
if url is not None:
|
||||
webhook.url = url
|
||||
if secret is not None:
|
||||
webhook.secret = secret
|
||||
if description is not None:
|
||||
webhook.description = description
|
||||
if is_active is not None:
|
||||
webhook.is_active = is_active
|
||||
|
||||
webhook.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(webhook)
|
||||
return webhook
|
||||
|
||||
|
||||
async def delete_webhook(db: AsyncSession, webhook: Webhook) -> None:
|
||||
"""Удалить webhook."""
|
||||
await db.delete(webhook)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def record_webhook_delivery(
|
||||
db: AsyncSession,
|
||||
webhook_id: int,
|
||||
event_type: str,
|
||||
payload: dict,
|
||||
status: str,
|
||||
response_status: Optional[int] = None,
|
||||
response_body: Optional[str] = None,
|
||||
error_message: Optional[str] = None,
|
||||
attempt_number: int = 1,
|
||||
) -> WebhookDelivery:
|
||||
"""Записать попытку доставки webhook."""
|
||||
delivery = WebhookDelivery(
|
||||
webhook_id=webhook_id,
|
||||
event_type=event_type,
|
||||
payload=payload,
|
||||
status=status,
|
||||
response_status=response_status,
|
||||
response_body=response_body,
|
||||
error_message=error_message,
|
||||
attempt_number=attempt_number,
|
||||
delivered_at=datetime.utcnow() if status == "success" else None,
|
||||
)
|
||||
db.add(delivery)
|
||||
await db.commit()
|
||||
await db.refresh(delivery)
|
||||
return delivery
|
||||
|
||||
|
||||
async def update_webhook_stats(
|
||||
db: AsyncSession,
|
||||
webhook: Webhook,
|
||||
success: bool,
|
||||
) -> Webhook:
|
||||
"""Обновить статистику webhook."""
|
||||
if success:
|
||||
webhook.success_count += 1
|
||||
else:
|
||||
webhook.failure_count += 1
|
||||
webhook.last_triggered_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(webhook)
|
||||
return webhook
|
||||
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import (
|
||||
)
|
||||
from sqlalchemy.pool import NullPool, AsyncAdaptedQueuePool
|
||||
from sqlalchemy import event, text, bindparam, inspect
|
||||
from sqlalchemy.exc import ProgrammingError
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.exc import OperationalError, InterfaceError
|
||||
import time
|
||||
@@ -417,10 +418,46 @@ batch_ops = BatchOperations()
|
||||
|
||||
async def init_db():
|
||||
"""Инициализация БД с оптимизациями"""
|
||||
logger.info("Создание таблиц базы данных...")
|
||||
logger.info("🚀 Создание таблиц базы данных...")
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(lambda sync_conn: Base.metadata.create_all(sync_conn, checkfirst=True))
|
||||
except (ProgrammingError, Exception) as e:
|
||||
# Игнорируем ошибки дублирования индексов/таблиц - они уже существуют
|
||||
# Это может произойти если таблицы были созданы вручную или через миграции
|
||||
error_str = str(e).lower()
|
||||
error_type = type(e).__name__.lower()
|
||||
|
||||
# Проверяем оригинальную ошибку для asyncpg
|
||||
orig_error = getattr(e, "orig", None)
|
||||
if orig_error:
|
||||
orig_type = type(orig_error).__name__.lower()
|
||||
if "duplicatetableerror" in orig_type or "duplicatekeyerror" in orig_type:
|
||||
logger.warning(
|
||||
"⚠️ Некоторые индексы/таблицы уже существуют в БД, это нормально. "
|
||||
"Продолжаем инициализацию..."
|
||||
)
|
||||
return
|
||||
|
||||
# Проверяем, является ли это ошибкой дублирования
|
||||
is_duplicate_error = (
|
||||
"already exists" in error_str
|
||||
or "duplicate" in error_str
|
||||
or "duplicatetableerror" in error_type
|
||||
or "duplicatekeyerror" in error_type
|
||||
)
|
||||
|
||||
if is_duplicate_error:
|
||||
logger.warning(
|
||||
"⚠️ Некоторые объекты БД уже существуют (таблицы/индексы), это нормально. "
|
||||
"Продолжаем инициализацию..."
|
||||
)
|
||||
# Продолжаем выполнение, так как основные таблицы могут быть созданы
|
||||
else:
|
||||
# Для других ошибок пробрасываем исключение
|
||||
logger.error(f"❌ Ошибка при создании таблиц: {e}")
|
||||
raise
|
||||
|
||||
if not IS_SQLITE:
|
||||
logger.info("Создание индексов для оптимизации...")
|
||||
|
||||
+271
-2
@@ -46,6 +46,25 @@ server_squad_promo_groups = Table(
|
||||
)
|
||||
|
||||
|
||||
# M2M таблица для связи тарифов с промогруппами (доступ к тарифу)
|
||||
tariff_promo_groups = Table(
|
||||
"tariff_promo_groups",
|
||||
Base.metadata,
|
||||
Column(
|
||||
"tariff_id",
|
||||
Integer,
|
||||
ForeignKey("tariffs.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
Column(
|
||||
"promo_group_id",
|
||||
Integer,
|
||||
ForeignKey("promo_groups.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class UserStatus(Enum):
|
||||
ACTIVE = "active"
|
||||
BLOCKED = "blocked"
|
||||
@@ -87,6 +106,7 @@ class PaymentMethod(Enum):
|
||||
WATA = "wata"
|
||||
PLATEGA = "platega"
|
||||
CLOUDPAYMENTS = "cloudpayments"
|
||||
FREEKASSA = "freekassa"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
@@ -545,6 +565,73 @@ class CloudPaymentsPayment(Base):
|
||||
)
|
||||
|
||||
|
||||
class FreekassaPayment(Base):
|
||||
__tablename__ = "freekassa_payments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
# Идентификаторы
|
||||
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш ID заказа
|
||||
freekassa_order_id = Column(String(64), unique=True, nullable=True, index=True) # intid от Freekassa
|
||||
|
||||
# Суммы
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default="RUB")
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# Статусы
|
||||
status = Column(String(32), nullable=False, default="pending") # pending, success, failed, expired
|
||||
is_paid = Column(Boolean, default=False)
|
||||
|
||||
# Данные платежа
|
||||
payment_url = Column(Text, nullable=True)
|
||||
payment_system_id = Column(Integer, nullable=True) # ID платежной системы FK
|
||||
|
||||
# Метаданные
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, nullable=True)
|
||||
|
||||
# Временные метки
|
||||
paid_at = Column(DateTime, nullable=True)
|
||||
expires_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
# Связь с транзакцией
|
||||
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", backref="freekassa_payments")
|
||||
transaction = relationship("Transaction", backref="freekassa_payment")
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status == "pending"
|
||||
|
||||
@property
|
||||
def is_success(self) -> bool:
|
||||
return self.status == "success" and self.is_paid
|
||||
|
||||
@property
|
||||
def is_failed(self) -> bool:
|
||||
return self.status in ["failed", "expired"]
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return (
|
||||
"<FreekassaPayment(id={0}, order_id={1}, amount={2}₽, status={3})>".format(
|
||||
self.id,
|
||||
self.order_id,
|
||||
self.amount_rubles,
|
||||
self.status,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class PromoGroup(Base):
|
||||
__tablename__ = "promo_groups"
|
||||
|
||||
@@ -646,6 +733,82 @@ class UserPromoGroup(Base):
|
||||
return f"<UserPromoGroup(user_id={self.user_id}, promo_group_id={self.promo_group_id}, assigned_by='{self.assigned_by}')>"
|
||||
|
||||
|
||||
class Tariff(Base):
|
||||
"""Тарифный план для режима продаж 'Тарифы'."""
|
||||
__tablename__ = "tariffs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Основная информация
|
||||
name = Column(String(255), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
display_order = Column(Integer, default=0, nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
|
||||
# Параметры тарифа
|
||||
traffic_limit_gb = Column(Integer, nullable=False, default=100) # 0 = безлимит
|
||||
device_limit = Column(Integer, nullable=False, default=1)
|
||||
device_price_kopeks = Column(Integer, nullable=True, default=None) # Цена за доп. устройство (None = нельзя докупить)
|
||||
|
||||
# Сквады (серверы) доступные в тарифе
|
||||
allowed_squads = Column(JSON, default=list) # список UUID сквадов
|
||||
|
||||
# Цены на периоды в копейках (JSON: {"14": 30000, "30": 50000, "90": 120000, ...})
|
||||
period_prices = Column(JSON, nullable=False, default=dict)
|
||||
|
||||
# Уровень тарифа (для визуального отображения, 1 = базовый)
|
||||
tier_level = Column(Integer, default=1, nullable=False)
|
||||
|
||||
# Дополнительные настройки
|
||||
is_trial_available = Column(Boolean, default=False, nullable=False) # Можно ли взять триал на этом тарифе
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
# M2M связь с промогруппами (какие промогруппы имеют доступ к тарифу)
|
||||
allowed_promo_groups = relationship(
|
||||
"PromoGroup",
|
||||
secondary=tariff_promo_groups,
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
# Подписки на этом тарифе
|
||||
subscriptions = relationship("Subscription", back_populates="tariff")
|
||||
|
||||
@property
|
||||
def is_unlimited_traffic(self) -> bool:
|
||||
"""Проверяет, безлимитный ли трафик."""
|
||||
return self.traffic_limit_gb == 0
|
||||
|
||||
def get_price_for_period(self, period_days: int) -> Optional[int]:
|
||||
"""Возвращает цену в копейках для указанного периода."""
|
||||
prices = self.period_prices or {}
|
||||
return prices.get(str(period_days))
|
||||
|
||||
def get_available_periods(self) -> List[int]:
|
||||
"""Возвращает список доступных периодов в днях."""
|
||||
prices = self.period_prices or {}
|
||||
return sorted([int(p) for p in prices.keys()])
|
||||
|
||||
def get_price_rubles(self, period_days: int) -> Optional[float]:
|
||||
"""Возвращает цену в рублях для указанного периода."""
|
||||
price_kopeks = self.get_price_for_period(period_days)
|
||||
if price_kopeks is not None:
|
||||
return price_kopeks / 100
|
||||
return None
|
||||
|
||||
def is_available_for_promo_group(self, promo_group_id: Optional[int]) -> bool:
|
||||
"""Проверяет, доступен ли тариф для указанной промогруппы."""
|
||||
if not self.allowed_promo_groups:
|
||||
return True # Если нет ограничений - доступен всем
|
||||
if promo_group_id is None:
|
||||
return True # Если у пользователя нет группы - доступен
|
||||
return any(pg.id == promo_group_id for pg in self.allowed_promo_groups)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Tariff(id={self.id}, name='{self.name}', tier={self.tier_level}, active={self.is_active})>"
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
@@ -657,7 +820,7 @@ class User(Base):
|
||||
status = Column(String(20), default=UserStatus.ACTIVE.value)
|
||||
language = Column(String(5), default="ru")
|
||||
balance_kopeks = Column(Integer, default=0)
|
||||
used_promocodes = Column(Integer, default=0)
|
||||
used_promocodes = Column(Integer, default=0)
|
||||
has_had_paid_subscription = Column(Boolean, default=False, nullable=False)
|
||||
referred_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
referral_code = Column(String(20), unique=True, nullable=True)
|
||||
@@ -665,6 +828,17 @@ class User(Base):
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
last_activity = Column(DateTime, default=func.now())
|
||||
remnawave_uuid = Column(String(255), nullable=True, unique=True)
|
||||
|
||||
# Cabinet authentication fields
|
||||
email = Column(String(255), unique=True, nullable=True, index=True)
|
||||
email_verified = Column(Boolean, default=False, nullable=False)
|
||||
email_verified_at = Column(DateTime, nullable=True)
|
||||
password_hash = Column(String(255), nullable=True)
|
||||
email_verification_token = Column(String(255), nullable=True)
|
||||
email_verification_expires = Column(DateTime, nullable=True)
|
||||
password_reset_token = Column(String(255), nullable=True)
|
||||
password_reset_expires = Column(DateTime, nullable=True)
|
||||
cabinet_last_login = Column(DateTime, nullable=True)
|
||||
broadcasts = relationship("BroadcastHistory", back_populates="admin")
|
||||
referrals = relationship("User", backref="referrer", remote_side=[id], foreign_keys="User.referred_by_id")
|
||||
subscription = relationship("Subscription", back_populates="user", uselist=False)
|
||||
@@ -688,6 +862,7 @@ class User(Base):
|
||||
promo_group = relationship("PromoGroup", back_populates="users")
|
||||
user_promo_groups = relationship("UserPromoGroup", back_populates="user", cascade="all, delete-orphan")
|
||||
poll_responses = relationship("PollResponse", back_populates="user")
|
||||
notification_settings = Column(JSON, nullable=True, default=dict)
|
||||
last_pinned_message_id = Column(Integer, nullable=True)
|
||||
|
||||
# Ограничения пользователя
|
||||
@@ -780,10 +955,14 @@ class Subscription(Base):
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
remnawave_short_uuid = Column(String(255), nullable=True)
|
||||
|
||||
# Тариф (для режима продаж "Тарифы")
|
||||
tariff_id = Column(Integer, ForeignKey("tariffs.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
user = relationship("User", back_populates="subscription")
|
||||
tariff = relationship("Tariff", back_populates="subscriptions")
|
||||
discount_offers = relationship("DiscountOffer", back_populates="subscription")
|
||||
temporary_accesses = relationship("SubscriptionTemporaryAccess", back_populates="subscription")
|
||||
|
||||
@@ -993,6 +1172,7 @@ class PromoCode(Base):
|
||||
valid_until = Column(DateTime, nullable=True)
|
||||
|
||||
is_active = Column(Boolean, default=True)
|
||||
first_purchase_only = Column(Boolean, default=False) # Только для первой покупки
|
||||
|
||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
@@ -1939,3 +2119,92 @@ class ButtonClickLog(Base):
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ButtonClickLog id={self.id} button='{self.button_id}' user={self.user_id} at={self.clicked_at}>"
|
||||
|
||||
|
||||
class Webhook(Base):
|
||||
"""Webhook конфигурация для подписки на события."""
|
||||
__tablename__ = "webhooks"
|
||||
__table_args__ = (
|
||||
Index("ix_webhooks_event_type", "event_type"),
|
||||
Index("ix_webhooks_is_active", "is_active"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
url = Column(Text, nullable=False)
|
||||
secret = Column(String(128), nullable=True) # Секрет для подписи payload
|
||||
event_type = Column(String(50), nullable=False) # user.created, payment.completed, ticket.created, etc.
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
last_triggered_at = Column(DateTime, nullable=True)
|
||||
failure_count = Column(Integer, default=0, nullable=False)
|
||||
success_count = Column(Integer, default=0, nullable=False)
|
||||
|
||||
deliveries = relationship("WebhookDelivery", back_populates="webhook", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
status = "active" if self.is_active else "inactive"
|
||||
return f"<Webhook id={self.id} name='{self.name}' event='{self.event_type}' status={status}>"
|
||||
|
||||
|
||||
class WebhookDelivery(Base):
|
||||
"""История доставки webhooks."""
|
||||
__tablename__ = "webhook_deliveries"
|
||||
__table_args__ = (
|
||||
Index("ix_webhook_deliveries_webhook_created", "webhook_id", "created_at"),
|
||||
Index("ix_webhook_deliveries_status", "status"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
webhook_id = Column(Integer, ForeignKey("webhooks.id", ondelete="CASCADE"), nullable=False)
|
||||
event_type = Column(String(50), nullable=False)
|
||||
payload = Column(JSON, nullable=False) # Отправленный payload
|
||||
response_status = Column(Integer, nullable=True) # HTTP статус ответа
|
||||
response_body = Column(Text, nullable=True) # Тело ответа (может быть обрезано)
|
||||
status = Column(String(20), nullable=False) # pending, success, failed
|
||||
error_message = Column(Text, nullable=True)
|
||||
attempt_number = Column(Integer, default=1, nullable=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
delivered_at = Column(DateTime, nullable=True)
|
||||
next_retry_at = Column(DateTime, nullable=True)
|
||||
|
||||
webhook = relationship("Webhook", back_populates="deliveries")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<WebhookDelivery id={self.id} webhook_id={self.webhook_id} status='{self.status}' event='{self.event_type}'>"
|
||||
|
||||
|
||||
class CabinetRefreshToken(Base):
|
||||
"""Refresh tokens for cabinet JWT authentication."""
|
||||
__tablename__ = "cabinet_refresh_tokens"
|
||||
__table_args__ = (
|
||||
Index("ix_cabinet_refresh_tokens_user", "user_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
token_hash = Column(String(255), unique=True, nullable=False, index=True)
|
||||
device_info = Column(String(500), nullable=True)
|
||||
expires_at = Column(DateTime, nullable=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
revoked_at = Column(DateTime, nullable=True)
|
||||
|
||||
user = relationship("User", backref="cabinet_tokens")
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
return datetime.utcnow() > self.expires_at
|
||||
|
||||
@property
|
||||
def is_revoked(self) -> bool:
|
||||
return self.revoked_at is not None
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
return not self.is_expired and not self.is_revoked
|
||||
|
||||
def __repr__(self) -> str:
|
||||
status = "valid" if self.is_valid else ("revoked" if self.is_revoked else "expired")
|
||||
return f"<CabinetRefreshToken id={self.id} user_id={self.user_id} status={status}>"
|
||||
@@ -1289,6 +1289,118 @@ async def ensure_wata_payment_schema() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def create_freekassa_payments_table():
|
||||
"""Создаёт таблицу freekassa_payments для платежей через Freekassa."""
|
||||
table_exists = await check_table_exists('freekassa_payments')
|
||||
if table_exists:
|
||||
logger.info("Таблица freekassa_payments уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
create_sql = """
|
||||
CREATE TABLE freekassa_payments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
order_id VARCHAR(64) NOT NULL UNIQUE,
|
||||
freekassa_order_id VARCHAR(64) NULL UNIQUE,
|
||||
amount_kopeks INTEGER NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
|
||||
description TEXT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
is_paid BOOLEAN NOT NULL DEFAULT 0,
|
||||
payment_url TEXT NULL,
|
||||
payment_system_id INTEGER NULL,
|
||||
metadata_json JSON NULL,
|
||||
callback_payload JSON NULL,
|
||||
paid_at DATETIME NULL,
|
||||
expires_at DATETIME NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
transaction_id INTEGER NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_freekassa_user_id ON freekassa_payments(user_id);
|
||||
CREATE UNIQUE INDEX idx_freekassa_order_id ON freekassa_payments(order_id);
|
||||
CREATE UNIQUE INDEX idx_freekassa_fk_order_id ON freekassa_payments(freekassa_order_id);
|
||||
"""
|
||||
|
||||
elif db_type == 'postgresql':
|
||||
create_sql = """
|
||||
CREATE TABLE freekassa_payments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
order_id VARCHAR(64) NOT NULL UNIQUE,
|
||||
freekassa_order_id VARCHAR(64) NULL UNIQUE,
|
||||
amount_kopeks INTEGER NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
|
||||
description TEXT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
is_paid BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
payment_url TEXT NULL,
|
||||
payment_system_id INTEGER NULL,
|
||||
metadata_json JSON NULL,
|
||||
callback_payload JSON NULL,
|
||||
paid_at TIMESTAMP NULL,
|
||||
expires_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
transaction_id INTEGER NULL REFERENCES transactions(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_freekassa_user_id ON freekassa_payments(user_id);
|
||||
CREATE UNIQUE INDEX idx_freekassa_order_id ON freekassa_payments(order_id);
|
||||
CREATE UNIQUE INDEX idx_freekassa_fk_order_id ON freekassa_payments(freekassa_order_id);
|
||||
"""
|
||||
|
||||
elif db_type == 'mysql':
|
||||
create_sql = """
|
||||
CREATE TABLE freekassa_payments (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
order_id VARCHAR(64) NOT NULL UNIQUE,
|
||||
freekassa_order_id VARCHAR(64) NULL UNIQUE,
|
||||
amount_kopeks INT NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
|
||||
description TEXT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
is_paid BOOLEAN NOT NULL DEFAULT 0,
|
||||
payment_url TEXT NULL,
|
||||
payment_system_id INT NULL,
|
||||
metadata_json JSON NULL,
|
||||
callback_payload JSON NULL,
|
||||
paid_at DATETIME NULL,
|
||||
expires_at DATETIME NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
transaction_id INT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_freekassa_user_id ON freekassa_payments(user_id);
|
||||
CREATE UNIQUE INDEX idx_freekassa_order_id ON freekassa_payments(order_id);
|
||||
CREATE UNIQUE INDEX idx_freekassa_fk_order_id ON freekassa_payments(freekassa_order_id);
|
||||
"""
|
||||
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для таблицы freekassa_payments: {db_type}")
|
||||
return False
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
logger.info("Таблица freekassa_payments успешно создана")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания таблицы freekassa_payments: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_discount_offers_table():
|
||||
table_exists = await check_table_exists('discount_offers')
|
||||
if table_exists:
|
||||
@@ -1947,6 +2059,37 @@ async def ensure_user_promo_offer_discount_columns():
|
||||
return False
|
||||
|
||||
|
||||
async def ensure_user_notification_settings_column() -> bool:
|
||||
"""Ensure notification_settings column exists in users table."""
|
||||
try:
|
||||
column_exists = await check_column_exists('users', 'notification_settings')
|
||||
|
||||
if column_exists:
|
||||
return True
|
||||
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
column_def = 'TEXT NULL'
|
||||
elif db_type == 'postgresql':
|
||||
column_def = 'JSONB NULL'
|
||||
elif db_type == 'mysql':
|
||||
column_def = 'JSON NULL'
|
||||
else:
|
||||
column_def = 'TEXT NULL'
|
||||
|
||||
await conn.execute(text(
|
||||
f"ALTER TABLE users ADD COLUMN notification_settings {column_def}"
|
||||
))
|
||||
|
||||
logger.info("✅ Колонка notification_settings для users добавлена")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления колонки notification_settings: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def ensure_promo_offer_template_active_duration_column() -> bool:
|
||||
try:
|
||||
column_exists = await check_column_exists('promo_offer_templates', 'active_discount_hours')
|
||||
@@ -3351,6 +3494,54 @@ async def add_user_restriction_columns() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def add_user_cabinet_columns() -> bool:
|
||||
"""Add cabinet (personal account) columns to users table."""
|
||||
cabinet_columns = [
|
||||
("email", "VARCHAR(255)", "VARCHAR(255)", "VARCHAR(255)"),
|
||||
("email_verified", "BOOLEAN DEFAULT 0", "BOOLEAN DEFAULT FALSE", "TINYINT(1) DEFAULT 0"),
|
||||
("email_verified_at", "DATETIME", "TIMESTAMP", "DATETIME"),
|
||||
("password_hash", "VARCHAR(255)", "VARCHAR(255)", "VARCHAR(255)"),
|
||||
("email_verification_token", "VARCHAR(255)", "VARCHAR(255)", "VARCHAR(255)"),
|
||||
("email_verification_expires", "DATETIME", "TIMESTAMP", "DATETIME"),
|
||||
("password_reset_token", "VARCHAR(255)", "VARCHAR(255)", "VARCHAR(255)"),
|
||||
("password_reset_expires", "DATETIME", "TIMESTAMP", "DATETIME"),
|
||||
("cabinet_last_login", "DATETIME", "TIMESTAMP", "DATETIME"),
|
||||
]
|
||||
|
||||
try:
|
||||
db_type = await get_database_type()
|
||||
added_count = 0
|
||||
|
||||
for col_name, sqlite_type, pg_type, mysql_type in cabinet_columns:
|
||||
if await check_column_exists('users', col_name):
|
||||
continue
|
||||
|
||||
async with engine.begin() as conn:
|
||||
if db_type == 'sqlite':
|
||||
col_type = sqlite_type
|
||||
elif db_type == 'postgresql':
|
||||
col_type = pg_type
|
||||
else:
|
||||
col_type = mysql_type
|
||||
|
||||
await conn.execute(
|
||||
text(f"ALTER TABLE users ADD COLUMN {col_name} {col_type}")
|
||||
)
|
||||
added_count += 1
|
||||
logger.info(f"✅ Добавлена колонка users.{col_name}")
|
||||
|
||||
if added_count == 0:
|
||||
logger.info("ℹ️ Все колонки cabinet уже существуют в таблице users")
|
||||
else:
|
||||
logger.info(f"✅ Добавлено {added_count} колонок cabinet в таблицу users")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления колонок cabinet: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def add_subscription_crypto_link_column() -> bool:
|
||||
column_exists = await check_column_exists('subscriptions', 'subscription_crypto_link')
|
||||
if column_exists:
|
||||
@@ -4630,6 +4821,38 @@ async def add_promocode_promo_group_column() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def add_promocode_first_purchase_only_column() -> bool:
|
||||
"""Добавляет колонку first_purchase_only в таблицу promocodes."""
|
||||
column_exists = await check_column_exists('promocodes', 'first_purchase_only')
|
||||
if column_exists:
|
||||
logger.info("Колонка first_purchase_only уже существует в promocodes")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
await conn.execute(
|
||||
text("ALTER TABLE promocodes ADD COLUMN first_purchase_only BOOLEAN DEFAULT 0")
|
||||
)
|
||||
elif db_type == 'postgresql':
|
||||
await conn.execute(
|
||||
text("ALTER TABLE promocodes ADD COLUMN first_purchase_only BOOLEAN DEFAULT FALSE")
|
||||
)
|
||||
elif db_type == 'mysql':
|
||||
await conn.execute(
|
||||
text("ALTER TABLE promocodes ADD COLUMN first_purchase_only BOOLEAN DEFAULT FALSE")
|
||||
)
|
||||
|
||||
logger.info("✅ Добавлена колонка first_purchase_only в promocodes")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка добавления first_purchase_only в promocodes: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def migrate_contest_templates_prize_columns() -> bool:
|
||||
"""Миграция contest_templates: prize_days -> prize_type + prize_value."""
|
||||
try:
|
||||
@@ -4826,6 +5049,203 @@ async def add_transaction_receipt_columns() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# МИГРАЦИИ ДЛЯ РЕЖИМА ТАРИФОВ
|
||||
# =============================================================================
|
||||
|
||||
async def create_tariffs_table() -> bool:
|
||||
"""Создаёт таблицу тарифов для режима продаж 'Тарифы'."""
|
||||
try:
|
||||
if await check_table_exists('tariffs'):
|
||||
logger.info("ℹ️ Таблица tariffs уже существует")
|
||||
return True
|
||||
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE tariffs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
display_order INTEGER DEFAULT 0 NOT NULL,
|
||||
is_active BOOLEAN DEFAULT 1 NOT NULL,
|
||||
traffic_limit_gb INTEGER DEFAULT 100 NOT NULL,
|
||||
device_limit INTEGER DEFAULT 1 NOT NULL,
|
||||
allowed_squads JSON DEFAULT '[]',
|
||||
period_prices JSON DEFAULT '{}' NOT NULL,
|
||||
tier_level INTEGER DEFAULT 1 NOT NULL,
|
||||
is_trial_available BOOLEAN DEFAULT 0 NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""))
|
||||
elif db_type == 'postgresql':
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE tariffs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
display_order INTEGER DEFAULT 0 NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE NOT NULL,
|
||||
traffic_limit_gb INTEGER DEFAULT 100 NOT NULL,
|
||||
device_limit INTEGER DEFAULT 1 NOT NULL,
|
||||
allowed_squads JSON DEFAULT '[]',
|
||||
period_prices JSON DEFAULT '{}' NOT NULL,
|
||||
tier_level INTEGER DEFAULT 1 NOT NULL,
|
||||
is_trial_available BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
else: # MySQL
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE tariffs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
display_order INT DEFAULT 0 NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE NOT NULL,
|
||||
traffic_limit_gb INT DEFAULT 100 NOT NULL,
|
||||
device_limit INT DEFAULT 1 NOT NULL,
|
||||
allowed_squads JSON DEFAULT (JSON_ARRAY()),
|
||||
period_prices JSON NOT NULL,
|
||||
tier_level INT DEFAULT 1 NOT NULL,
|
||||
is_trial_available BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
)
|
||||
"""))
|
||||
|
||||
logger.info("✅ Таблица tariffs создана")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка создания таблицы tariffs: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_tariff_promo_groups_table() -> bool:
|
||||
"""Создаёт связующую таблицу tariff_promo_groups для M2M связи тарифов и промогрупп."""
|
||||
try:
|
||||
if await check_table_exists('tariff_promo_groups'):
|
||||
logger.info("ℹ️ Таблица tariff_promo_groups уже существует")
|
||||
return True
|
||||
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE tariff_promo_groups (
|
||||
tariff_id INTEGER NOT NULL,
|
||||
promo_group_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (tariff_id, promo_group_id),
|
||||
FOREIGN KEY (tariff_id) REFERENCES tariffs(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE
|
||||
)
|
||||
"""))
|
||||
elif db_type == 'postgresql':
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE tariff_promo_groups (
|
||||
tariff_id INTEGER NOT NULL REFERENCES tariffs(id) ON DELETE CASCADE,
|
||||
promo_group_id INTEGER NOT NULL REFERENCES promo_groups(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (tariff_id, promo_group_id)
|
||||
)
|
||||
"""))
|
||||
else: # MySQL
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE tariff_promo_groups (
|
||||
tariff_id INT NOT NULL,
|
||||
promo_group_id INT NOT NULL,
|
||||
PRIMARY KEY (tariff_id, promo_group_id),
|
||||
FOREIGN KEY (tariff_id) REFERENCES tariffs(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE
|
||||
)
|
||||
"""))
|
||||
|
||||
logger.info("✅ Таблица tariff_promo_groups создана")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка создания таблицы tariff_promo_groups: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def add_subscription_tariff_id_column() -> bool:
|
||||
"""Добавляет колонку tariff_id в таблицу subscriptions."""
|
||||
try:
|
||||
if await check_column_exists('subscriptions', 'tariff_id'):
|
||||
logger.info("ℹ️ Колонка tariff_id уже существует в subscriptions")
|
||||
return True
|
||||
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE subscriptions ADD COLUMN tariff_id INTEGER REFERENCES tariffs(id)"
|
||||
))
|
||||
elif db_type == 'postgresql':
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE subscriptions ADD COLUMN tariff_id INTEGER REFERENCES tariffs(id) ON DELETE SET NULL"
|
||||
))
|
||||
# Создаём индекс
|
||||
await conn.execute(text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_subscriptions_tariff_id ON subscriptions(tariff_id)"
|
||||
))
|
||||
else: # MySQL
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE subscriptions ADD COLUMN tariff_id INT NULL"
|
||||
))
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE subscriptions ADD CONSTRAINT fk_subscriptions_tariff "
|
||||
"FOREIGN KEY (tariff_id) REFERENCES tariffs(id) ON DELETE SET NULL"
|
||||
))
|
||||
await conn.execute(text(
|
||||
"CREATE INDEX ix_subscriptions_tariff_id ON subscriptions(tariff_id)"
|
||||
))
|
||||
|
||||
logger.info("✅ Колонка tariff_id добавлена в subscriptions")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка добавления колонки tariff_id: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def add_tariff_device_price_column() -> bool:
|
||||
"""Добавляет колонку device_price_kopeks в таблицу tariffs."""
|
||||
try:
|
||||
if await check_column_exists('tariffs', 'device_price_kopeks'):
|
||||
logger.info("ℹ️ Колонка device_price_kopeks уже существует в tariffs")
|
||||
return True
|
||||
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE tariffs ADD COLUMN device_price_kopeks INTEGER DEFAULT NULL"
|
||||
))
|
||||
elif db_type == 'postgresql':
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE tariffs ADD COLUMN device_price_kopeks INTEGER DEFAULT NULL"
|
||||
))
|
||||
else: # MySQL
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE tariffs ADD COLUMN device_price_kopeks INT DEFAULT NULL"
|
||||
))
|
||||
|
||||
logger.info("✅ Колонка device_price_kopeks добавлена в tariffs")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка добавления колонки device_price_kopeks: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def run_universal_migration():
|
||||
logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===")
|
||||
|
||||
@@ -4969,6 +5389,13 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Не удалось обновить схему Wata payments")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ FREEKASSA ===")
|
||||
freekassa_created = await create_freekassa_payments_table()
|
||||
if freekassa_created:
|
||||
logger.info("✅ Таблица Freekassa payments готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей Freekassa payments")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ DISCOUNT_OFFERS ===")
|
||||
discount_created = await create_discount_offers_table()
|
||||
if discount_created:
|
||||
@@ -5038,6 +5465,13 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Не удалось обновить пользовательские промо-скидки")
|
||||
|
||||
logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ NOTIFICATION_SETTINGS ===")
|
||||
notification_settings_ready = await ensure_user_notification_settings_column()
|
||||
if notification_settings_ready:
|
||||
logger.info("✅ Колонка notification_settings готова")
|
||||
else:
|
||||
logger.warning("⚠️ Не удалось добавить колонку notification_settings")
|
||||
|
||||
effect_types_updated = await migrate_discount_offer_effect_types()
|
||||
if effect_types_updated:
|
||||
logger.info("✅ Типы эффектов промо-предложений обновлены")
|
||||
@@ -5085,6 +5519,13 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с добавлением promo_group_id в promocodes")
|
||||
|
||||
logger.info("=== ДОБАВЛЕНИЕ FIRST_PURCHASE_ONLY В PROMOCODES ===")
|
||||
first_purchase_ready = await add_promocode_first_purchase_only_column()
|
||||
if first_purchase_ready:
|
||||
logger.info("✅ Колонка first_purchase_only в promocodes готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с добавлением first_purchase_only в promocodes")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ MAIN_MENU_BUTTONS ===")
|
||||
main_menu_buttons_created = await create_main_menu_buttons_table()
|
||||
if main_menu_buttons_created:
|
||||
@@ -5196,6 +5637,13 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с добавлением колонок ограничений пользователей")
|
||||
|
||||
logger.info("=== ДОБАВЛЕНИЕ КОЛОНОК ЛИЧНОГО КАБИНЕТА ===")
|
||||
cabinet_added = await add_user_cabinet_columns()
|
||||
if cabinet_added:
|
||||
logger.info("✅ Колонки личного кабинета готовы")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с добавлением колонок личного кабинета")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ АУДИТА ПОДДЕРЖКИ ===")
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
@@ -5275,6 +5723,31 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с настройкой доступа серверов к промогруппам")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦ ДЛЯ РЕЖИМА ТАРИФОВ ===")
|
||||
tariffs_table_ready = await create_tariffs_table()
|
||||
if tariffs_table_ready:
|
||||
logger.info("✅ Таблица tariffs готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей tariffs")
|
||||
|
||||
tariff_promo_groups_ready = await create_tariff_promo_groups_table()
|
||||
if tariff_promo_groups_ready:
|
||||
logger.info("✅ Таблица tariff_promo_groups готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей tariff_promo_groups")
|
||||
|
||||
tariff_id_column_ready = await add_subscription_tariff_id_column()
|
||||
if tariff_id_column_ready:
|
||||
logger.info("✅ Колонка tariff_id в subscriptions готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с колонкой tariff_id в subscriptions")
|
||||
|
||||
device_price_column_ready = await add_tariff_device_price_column()
|
||||
if device_price_column_ready:
|
||||
logger.info("✅ Колонка device_price_kopeks в tariffs готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с колонкой device_price_kopeks в tariffs")
|
||||
|
||||
logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===")
|
||||
fk_updated = await fix_foreign_keys_for_user_deletion()
|
||||
if fk_updated:
|
||||
@@ -5384,6 +5857,7 @@ async def check_migration_status():
|
||||
"users_promo_offer_discount_source_column": False,
|
||||
"users_promo_offer_discount_expires_column": False,
|
||||
"users_referral_commission_percent_column": False,
|
||||
"users_notification_settings_column": False,
|
||||
"subscription_crypto_link_column": False,
|
||||
"subscription_modem_enabled_column": False,
|
||||
"subscription_purchased_traffic_column": False,
|
||||
@@ -5451,6 +5925,7 @@ async def check_migration_status():
|
||||
status["users_promo_offer_discount_source_column"] = await check_column_exists('users', 'promo_offer_discount_source')
|
||||
status["users_promo_offer_discount_expires_column"] = await check_column_exists('users', 'promo_offer_discount_expires_at')
|
||||
status["users_referral_commission_percent_column"] = await check_column_exists('users', 'referral_commission_percent')
|
||||
status["users_notification_settings_column"] = await check_column_exists('users', 'notification_settings')
|
||||
status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link')
|
||||
status["subscription_modem_enabled_column"] = await check_column_exists('subscriptions', 'modem_enabled')
|
||||
status["subscription_purchased_traffic_column"] = await check_column_exists('subscriptions', 'purchased_traffic_gb')
|
||||
@@ -5534,6 +6009,7 @@ async def check_migration_status():
|
||||
"users_promo_offer_discount_source_column": "Колонка источника промо-скидки у пользователей",
|
||||
"users_promo_offer_discount_expires_column": "Колонка срока действия промо-скидки у пользователей",
|
||||
"users_referral_commission_percent_column": "Колонка процента реферальной комиссии у пользователей",
|
||||
"users_notification_settings_column": "Колонка notification_settings у пользователей",
|
||||
"subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions",
|
||||
"subscription_modem_enabled_column": "Колонка modem_enabled в subscriptions",
|
||||
"subscription_purchased_traffic_column": "Колонка purchased_traffic_gb в subscriptions",
|
||||
|
||||
Vendored
+29
-1
@@ -538,10 +538,25 @@ class RemnaWaveAPI:
|
||||
user = self._parse_user(response['response'])
|
||||
return await self.enrich_user_with_happ_link(user)
|
||||
|
||||
async def revoke_user_subscription(self, uuid: str, new_short_uuid: Optional[str] = None) -> RemnaWaveUser:
|
||||
async def revoke_user_subscription(
|
||||
self,
|
||||
uuid: str,
|
||||
new_short_uuid: Optional[str] = None,
|
||||
revoke_only_passwords: bool = False
|
||||
) -> RemnaWaveUser:
|
||||
"""
|
||||
Отзывает подписку пользователя (меняет ссылку/пароли).
|
||||
|
||||
Args:
|
||||
uuid: UUID пользователя
|
||||
new_short_uuid: Новый короткий UUID (опционально, рекомендуется генерировать автоматически)
|
||||
revoke_only_passwords: Если True, меняются только пароли без изменения URL подписки
|
||||
"""
|
||||
data = {}
|
||||
if new_short_uuid:
|
||||
data['shortUuid'] = new_short_uuid
|
||||
if revoke_only_passwords:
|
||||
data['revokeOnlyPasswords'] = True
|
||||
|
||||
response = await self._make_request('POST', f'/api/users/{uuid}/actions/revoke', data)
|
||||
user = self._parse_user(response['response'])
|
||||
@@ -809,6 +824,19 @@ class RemnaWaveAPI:
|
||||
async def get_system_stats(self) -> Dict[str, Any]:
|
||||
response = await self._make_request('GET', '/api/system/stats')
|
||||
return response['response']
|
||||
|
||||
async def get_system_metadata(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Получает метаданные системы Remnawave.
|
||||
|
||||
Returns:
|
||||
Dict с полями:
|
||||
- version: версия Remnawave
|
||||
- build: {time, number} - информация о сборке
|
||||
- git: {backend: {commitSha}, node: {commitSha}} - информация о коммитах
|
||||
"""
|
||||
response = await self._make_request('GET', '/api/system/metadata')
|
||||
return response['response']
|
||||
|
||||
async def get_bandwidth_stats(self) -> Dict[str, Any]:
|
||||
response = await self._make_request('GET', '/api/system/stats/bandwidth')
|
||||
|
||||
Vendored
+84
-3
@@ -36,7 +36,10 @@ class WebhookServer:
|
||||
|
||||
if settings.is_cryptobot_enabled():
|
||||
self.app.router.add_post(settings.CRYPTOBOT_WEBHOOK_PATH, self._cryptobot_webhook_handler)
|
||||
|
||||
|
||||
if settings.is_freekassa_enabled():
|
||||
self.app.router.add_post(settings.FREEKASSA_WEBHOOK_PATH, self._freekassa_webhook_handler)
|
||||
|
||||
self.app.router.add_get('/health', self._health_check)
|
||||
|
||||
self.app.router.add_options(settings.TRIBUTE_WEBHOOK_PATH, self._options_handler)
|
||||
@@ -44,7 +47,9 @@ class WebhookServer:
|
||||
self.app.router.add_options(settings.MULENPAY_WEBHOOK_PATH, self._options_handler)
|
||||
if settings.is_cryptobot_enabled():
|
||||
self.app.router.add_options(settings.CRYPTOBOT_WEBHOOK_PATH, self._options_handler)
|
||||
|
||||
if settings.is_freekassa_enabled():
|
||||
self.app.router.add_options(settings.FREEKASSA_WEBHOOK_PATH, self._options_handler)
|
||||
|
||||
logger.info(f"Webhook сервер настроен:")
|
||||
logger.info(f" - Tribute webhook: POST {settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
if settings.is_mulenpay_enabled():
|
||||
@@ -56,6 +61,8 @@ class WebhookServer:
|
||||
)
|
||||
if settings.is_cryptobot_enabled():
|
||||
logger.info(f" - CryptoBot webhook: POST {settings.CRYPTOBOT_WEBHOOK_PATH}")
|
||||
if settings.is_freekassa_enabled():
|
||||
logger.info(f" - Freekassa webhook: POST {settings.FREEKASSA_WEBHOOK_PATH}")
|
||||
logger.info(f" - Health check: GET /health")
|
||||
|
||||
return self.app
|
||||
@@ -446,7 +453,81 @@ class WebhookServer:
|
||||
"service": "payment-webhooks",
|
||||
"tribute_enabled": settings.TRIBUTE_ENABLED,
|
||||
"cryptobot_enabled": settings.is_cryptobot_enabled(),
|
||||
"freekassa_enabled": settings.is_freekassa_enabled(),
|
||||
"port": settings.TRIBUTE_WEBHOOK_PORT,
|
||||
"tribute_path": settings.TRIBUTE_WEBHOOK_PATH,
|
||||
"cryptobot_path": settings.CRYPTOBOT_WEBHOOK_PATH if settings.is_cryptobot_enabled() else None
|
||||
"cryptobot_path": settings.CRYPTOBOT_WEBHOOK_PATH if settings.is_cryptobot_enabled() else None,
|
||||
"freekassa_path": settings.FREEKASSA_WEBHOOK_PATH if settings.is_freekassa_enabled() else None,
|
||||
})
|
||||
|
||||
async def _freekassa_webhook_handler(self, request: web.Request) -> web.Response:
|
||||
"""
|
||||
Обработчик webhook от Freekassa.
|
||||
|
||||
Freekassa отправляет POST запрос с form-data:
|
||||
- MERCHANT_ID: ID магазина
|
||||
- AMOUNT: Сумма платежа
|
||||
- MERCHANT_ORDER_ID: Наш order_id
|
||||
- SIGN: Подпись MD5(shop_id:amount:secret2:order_id)
|
||||
- intid: ID транзакции Freekassa
|
||||
- CUR_ID: ID валюты/платежной системы
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Получен Freekassa webhook: {request.method} {request.path}")
|
||||
|
||||
# Получаем IP клиента
|
||||
client_ip = request.headers.get("X-Forwarded-For", "").split(",")[0].strip()
|
||||
if not client_ip:
|
||||
client_ip = request.remote or "unknown"
|
||||
logger.info(f"Freekassa webhook IP: {client_ip}")
|
||||
|
||||
# Freekassa отправляет form-data
|
||||
try:
|
||||
form_data = await request.post()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка парсинга Freekassa form-data: {e}")
|
||||
return web.Response(text="NO", status=400)
|
||||
|
||||
logger.info(f"Freekassa webhook data: {dict(form_data)}")
|
||||
|
||||
# Извлекаем параметры
|
||||
merchant_id = int(form_data.get("MERCHANT_ID", 0))
|
||||
amount = float(form_data.get("AMOUNT", 0))
|
||||
order_id = form_data.get("MERCHANT_ORDER_ID", "")
|
||||
sign = form_data.get("SIGN", "")
|
||||
intid = form_data.get("intid", "")
|
||||
cur_id = form_data.get("CUR_ID")
|
||||
|
||||
if not order_id or not sign:
|
||||
logger.warning("Freekassa webhook: отсутствуют обязательные параметры")
|
||||
return web.Response(text="NO", status=400)
|
||||
|
||||
# Обрабатываем платеж через PaymentService
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
payment_service = PaymentService(self.bot)
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
success = await payment_service.process_freekassa_webhook(
|
||||
db=db,
|
||||
merchant_id=merchant_id,
|
||||
amount=amount,
|
||||
order_id=order_id,
|
||||
sign=sign,
|
||||
intid=intid,
|
||||
cur_id=int(cur_id) if cur_id else None,
|
||||
client_ip=client_ip,
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.info(f"Freekassa webhook обработан успешно: order_id={order_id}")
|
||||
# Freekassa ожидает YES в ответе
|
||||
return web.Response(text="YES", status=200)
|
||||
else:
|
||||
logger.error(f"Ошибка обработки Freekassa webhook: order_id={order_id}")
|
||||
return web.Response(text="NO", status=400)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Критическая ошибка обработки Freekassa webhook: {e}", exc_info=True)
|
||||
return web.Response(text="NO", status=500)
|
||||
|
||||
@@ -61,7 +61,7 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
},
|
||||
"payments": {
|
||||
"title": "💳 Платежные системы",
|
||||
"description": "YooKassa, CryptoBot, Heleket, CloudPayments, MulenPay, PAL24, Wata, Platega, Tribute и Telegram Stars.",
|
||||
"description": "YooKassa, CryptoBot, Heleket, CloudPayments, Freekassa, MulenPay, PAL24, Wata, Platega, Tribute и Telegram Stars.",
|
||||
"icon": "💳",
|
||||
"categories": (
|
||||
"PAYMENT",
|
||||
@@ -70,6 +70,7 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
"CRYPTOBOT",
|
||||
"HELEKET",
|
||||
"CLOUDPAYMENTS",
|
||||
"FREEKASSA",
|
||||
"MULENPAY",
|
||||
"PAL24",
|
||||
"WATA",
|
||||
@@ -257,6 +258,7 @@ def _get_group_status(group_key: str) -> Tuple[str, str]:
|
||||
"CryptoBot": settings.is_cryptobot_enabled(),
|
||||
"Platega": settings.is_platega_enabled(),
|
||||
"CloudPayments": settings.is_cloudpayments_enabled(),
|
||||
"Freekassa": settings.is_freekassa_enabled(),
|
||||
"MulenPay": settings.is_mulenpay_enabled(),
|
||||
"PAL24": settings.is_pal24_enabled(),
|
||||
"Tribute": settings.TRIBUTE_ENABLED,
|
||||
@@ -1334,6 +1336,9 @@ def _build_settings_keyboard(
|
||||
elif category_key == "CRYPTOBOT":
|
||||
label = texts.t("PAYMENT_CRYPTOBOT", "🪙 Криптовалюта (CryptoBot)")
|
||||
test_payment_buttons.append([_test_button(f"{label} · тест", "cryptobot")])
|
||||
elif category_key == "FREEKASSA":
|
||||
label = texts.t("PAYMENT_FREEKASSA", "💳 Freekassa")
|
||||
test_payment_buttons.append([_test_button(f"{label} · тест", "freekassa")])
|
||||
|
||||
if test_payment_buttons:
|
||||
rows.extend(test_payment_buttons)
|
||||
@@ -2331,6 +2336,47 @@ async def test_payment_provider(
|
||||
await _refresh_markup()
|
||||
return
|
||||
|
||||
if method == "freekassa":
|
||||
if not settings.is_freekassa_enabled():
|
||||
await callback.answer("❌ Freekassa отключена", show_alert=True)
|
||||
return
|
||||
|
||||
amount_kopeks = settings.FREEKASSA_MIN_AMOUNT_KOPEKS
|
||||
payment_result = await payment_service.create_freekassa_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description="Тестовый платеж Freekassa (админ)",
|
||||
email=getattr(db_user, "email", None),
|
||||
language=db_user.language or settings.DEFAULT_LANGUAGE,
|
||||
)
|
||||
|
||||
if not payment_result or not payment_result.get("payment_url"):
|
||||
await callback.answer("❌ Не удалось создать тестовый платеж Freekassa", show_alert=True)
|
||||
await _refresh_markup()
|
||||
return
|
||||
|
||||
payment_url = payment_result["payment_url"]
|
||||
message_text = (
|
||||
"🧪 <b>Тестовый платеж Freekassa</b>\n\n"
|
||||
f"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
|
||||
f"🆔 Order ID: {payment_result['order_id']}"
|
||||
)
|
||||
reply_markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="💳 Перейти к оплате",
|
||||
url=payment_url,
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode="HTML")
|
||||
await callback.answer("✅ Ссылка на платеж Freekassa отправлена", show_alert=True)
|
||||
await _refresh_markup()
|
||||
return
|
||||
|
||||
await callback.answer("❌ Неизвестный способ тестирования платежа", show_alert=True)
|
||||
await _refresh_markup()
|
||||
|
||||
|
||||
@@ -919,6 +919,8 @@ async def monitoring_statistics_callback(callback: CallbackQuery):
|
||||
queue_len = nalogo_status.get("queue_length", 0)
|
||||
total_amount = nalogo_status.get("total_amount", 0)
|
||||
running = nalogo_status.get("running", False)
|
||||
pending_count = nalogo_status.get("pending_verification_count", 0)
|
||||
pending_amount = nalogo_status.get("pending_verification_amount", 0)
|
||||
|
||||
nalogo_section = f"""
|
||||
🧾 <b>Чеки NaloGO:</b>
|
||||
@@ -926,6 +928,8 @@ async def monitoring_statistics_callback(callback: CallbackQuery):
|
||||
• В очереди: {queue_len} чек(ов)"""
|
||||
if queue_len > 0:
|
||||
nalogo_section += f"\n• На сумму: {total_amount:,.2f} ₽"
|
||||
if pending_count > 0:
|
||||
nalogo_section += f"\n⚠️ <b>Требуют проверки: {pending_count} ({pending_amount:,.2f} ₽)</b>"
|
||||
text += nalogo_section
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
@@ -940,6 +944,12 @@ async def monitoring_statistics_callback(callback: CallbackQuery):
|
||||
text=f"🧾 Отправить ({nalogo_status['queue_length']})",
|
||||
callback_data="admin_mon_nalogo_force_process"
|
||||
))
|
||||
pending_count = nalogo_status.get("pending_verification_count", 0)
|
||||
if pending_count > 0:
|
||||
nalogo_buttons.append(InlineKeyboardButton(
|
||||
text=f"⚠️ Проверить ({pending_count})",
|
||||
callback_data="admin_mon_nalogo_pending"
|
||||
))
|
||||
nalogo_buttons.append(InlineKeyboardButton(
|
||||
text="📊 Сверка чеков",
|
||||
callback_data="admin_mon_receipts_missing"
|
||||
@@ -948,7 +958,7 @@ async def monitoring_statistics_callback(callback: CallbackQuery):
|
||||
|
||||
buttons.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")])
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
break
|
||||
|
||||
@@ -1068,6 +1078,146 @@ async def nalogo_force_process_callback(callback: CallbackQuery):
|
||||
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_nalogo_pending")
|
||||
@admin_required
|
||||
async def nalogo_pending_callback(callback: CallbackQuery):
|
||||
"""Просмотр чеков ожидающих ручной проверки."""
|
||||
try:
|
||||
from app.services.nalogo_service import NaloGoService
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
nalogo_service = NaloGoService()
|
||||
receipts = await nalogo_service.get_pending_verification_receipts()
|
||||
|
||||
if not receipts:
|
||||
await callback.answer("✅ Нет чеков на проверку", show_alert=True)
|
||||
return
|
||||
|
||||
text = f"⚠️ <b>Чеки требующие проверки: {len(receipts)}</b>\n\n"
|
||||
text += "Проверьте в lknpd.nalog.ru созданы ли эти чеки.\n\n"
|
||||
|
||||
buttons = []
|
||||
for i, receipt in enumerate(receipts[:10], 1):
|
||||
payment_id = receipt.get("payment_id", "unknown")
|
||||
amount = receipt.get("amount", 0)
|
||||
created_at = receipt.get("created_at", "")[:16].replace("T", " ")
|
||||
error = receipt.get("error", "")[:50]
|
||||
|
||||
text += f"<b>{i}. {amount:,.2f} ₽</b>\n"
|
||||
text += f" 📅 {created_at}\n"
|
||||
text += f" 🆔 <code>{payment_id[:20]}...</code>\n"
|
||||
if error:
|
||||
text += f" ❌ {error}\n"
|
||||
text += "\n"
|
||||
|
||||
# Кнопки для каждого чека
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"✅ Создан ({i})",
|
||||
callback_data=f"admin_nalogo_verified:{payment_id[:30]}"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=f"🔄 Отправить ({i})",
|
||||
callback_data=f"admin_nalogo_retry:{payment_id[:30]}"
|
||||
),
|
||||
])
|
||||
|
||||
if len(receipts) > 10:
|
||||
text += f"\n... и ещё {len(receipts) - 10} чек(ов)"
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text="🗑 Очистить всё (проверено)",
|
||||
callback_data="admin_nalogo_clear_pending"
|
||||
)
|
||||
])
|
||||
buttons.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_statistics")])
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка просмотра очереди проверки: {e}")
|
||||
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_nalogo_verified:"))
|
||||
@admin_required
|
||||
async def nalogo_mark_verified_callback(callback: CallbackQuery):
|
||||
"""Пометить чек как созданный в налоговой."""
|
||||
try:
|
||||
from app.services.nalogo_service import NaloGoService
|
||||
|
||||
payment_id = callback.data.split(":", 1)[1]
|
||||
nalogo_service = NaloGoService()
|
||||
|
||||
# Помечаем как проверенный (чек был создан)
|
||||
removed = await nalogo_service.mark_pending_as_verified(
|
||||
payment_id, receipt_uuid=None, was_created=True
|
||||
)
|
||||
|
||||
if removed:
|
||||
await callback.answer(f"✅ Чек помечен как созданный", show_alert=True)
|
||||
# Обновляем список
|
||||
await nalogo_pending_callback(callback)
|
||||
else:
|
||||
await callback.answer("❌ Чек не найден", show_alert=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка пометки чека: {e}")
|
||||
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_nalogo_retry:"))
|
||||
@admin_required
|
||||
async def nalogo_retry_callback(callback: CallbackQuery):
|
||||
"""Повторно отправить чек в налоговую."""
|
||||
try:
|
||||
from app.services.nalogo_service import NaloGoService
|
||||
|
||||
payment_id = callback.data.split(":", 1)[1]
|
||||
nalogo_service = NaloGoService()
|
||||
|
||||
await callback.answer("🔄 Отправляю чек...", show_alert=False)
|
||||
|
||||
receipt_uuid = await nalogo_service.retry_pending_receipt(payment_id)
|
||||
|
||||
if receipt_uuid:
|
||||
await callback.answer(f"✅ Чек создан: {receipt_uuid}", show_alert=True)
|
||||
# Обновляем список
|
||||
await nalogo_pending_callback(callback)
|
||||
else:
|
||||
await callback.answer("❌ Не удалось создать чек", show_alert=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка повторной отправки чека: {e}")
|
||||
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_nalogo_clear_pending")
|
||||
@admin_required
|
||||
async def nalogo_clear_pending_callback(callback: CallbackQuery):
|
||||
"""Очистить всю очередь проверки."""
|
||||
try:
|
||||
from app.services.nalogo_service import NaloGoService
|
||||
|
||||
nalogo_service = NaloGoService()
|
||||
count = await nalogo_service.clear_pending_verification()
|
||||
|
||||
await callback.answer(f"✅ Очищено: {count} чек(ов)", show_alert=True)
|
||||
# Возвращаемся на статистику
|
||||
await callback.message.edit_text(
|
||||
"✅ Очередь проверки очищена",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_statistics")]
|
||||
])
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка очистки очереди: {e}")
|
||||
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_receipts_missing")
|
||||
@admin_required
|
||||
async def receipts_missing_callback(callback: CallbackQuery):
|
||||
|
||||
@@ -130,6 +130,21 @@ async def show_promocodes_list(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_promocodes_list_page(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Обработчик пагинации списка промокодов."""
|
||||
try:
|
||||
page = int(callback.data.split('_')[-1])
|
||||
except (ValueError, IndexError):
|
||||
page = 1
|
||||
await show_promocodes_list(callback, db_user, db, page=page)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_promocode_management(
|
||||
@@ -172,27 +187,39 @@ async def show_promocode_management(
|
||||
|
||||
if promo.valid_until:
|
||||
text += f"⏰ <b>Действует до:</b> {format_datetime(promo.valid_until)}\n"
|
||||
|
||||
|
||||
first_purchase_only = getattr(promo, 'first_purchase_only', False)
|
||||
first_purchase_emoji = "✅" if first_purchase_only else "❌"
|
||||
text += f"🆕 <b>Только первая покупка:</b> {first_purchase_emoji}\n"
|
||||
|
||||
text += f"📅 <b>Создан:</b> {format_datetime(promo.created_at)}\n"
|
||||
|
||||
|
||||
first_purchase_btn_text = "🆕 Первая покупка: ✅" if first_purchase_only else "🆕 Первая покупка: ❌"
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="✏️ Редактировать",
|
||||
text="✏️ Редактировать",
|
||||
callback_data=f"promo_edit_{promo.id}"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="🔄 Переключить статус",
|
||||
text="🔄 Переключить статус",
|
||||
callback_data=f"promo_toggle_{promo.id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="📊 Статистика",
|
||||
text=first_purchase_btn_text,
|
||||
callback_data=f"promo_toggle_first_{promo.id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="📊 Статистика",
|
||||
callback_data=f"promo_stats_{promo.id}"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="🗑️ Удалить",
|
||||
text="🗑️ Удалить",
|
||||
callback_data=f"promo_delete_{promo.id}"
|
||||
)
|
||||
],
|
||||
@@ -931,7 +958,31 @@ async def toggle_promocode_status(
|
||||
|
||||
status_text = "активирован" if new_status else "деактивирован"
|
||||
await callback.answer(f"✅ Промокод {status_text}", show_alert=True)
|
||||
|
||||
|
||||
await show_promocode_management(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_promocode_first_purchase(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Переключает режим 'только для первой покупки'."""
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
|
||||
new_status = not getattr(promo, 'first_purchase_only', False)
|
||||
await update_promocode(db, promo, first_purchase_only=new_status)
|
||||
|
||||
status_text = "включён" if new_status else "выключен"
|
||||
await callback.answer(f"✅ Режим 'первая покупка' {status_text}", show_alert=True)
|
||||
|
||||
await show_promocode_management(callback, db_user, db)
|
||||
|
||||
|
||||
@@ -1103,11 +1154,13 @@ async def show_general_promocode_stats(
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_promocodes_menu, F.data == "admin_promocodes")
|
||||
dp.callback_query.register(show_promocodes_list, F.data == "admin_promo_list")
|
||||
dp.callback_query.register(show_promocodes_list_page, F.data.startswith("admin_promo_list_page_"))
|
||||
dp.callback_query.register(start_promocode_creation, F.data == "admin_promo_create")
|
||||
dp.callback_query.register(select_promocode_type, F.data.startswith("promo_type_"))
|
||||
dp.callback_query.register(process_promo_group_selection, F.data.startswith("promo_select_group_"))
|
||||
|
||||
dp.callback_query.register(show_promocode_management, F.data.startswith("promo_manage_"))
|
||||
dp.callback_query.register(toggle_promocode_first_purchase, F.data.startswith("promo_toggle_first_"))
|
||||
dp.callback_query.register(toggle_promocode_status, F.data.startswith("promo_toggle_"))
|
||||
dp.callback_query.register(show_promocode_stats, F.data.startswith("promo_stats_"))
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+566
-499
File diff suppressed because it is too large
Load Diff
@@ -57,7 +57,7 @@ async def start_cryptobot_payment(
|
||||
assets_text = ", ".join(available_assets)
|
||||
|
||||
# Формируем текст сообщения в зависимости от настройки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
message_text = (
|
||||
f"🪙 <b>Пополнение криптовалютой</b>\n\n"
|
||||
f"Выберите сумму пополнения или введите вручную сумму "
|
||||
@@ -83,7 +83,7 @@ async def start_cryptobot_payment(
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
"""Handler for Freekassa balance top-up."""
|
||||
|
||||
import logging
|
||||
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _create_freekassa_payment_and_respond(
|
||||
message_or_callback,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
):
|
||||
"""
|
||||
Common logic for creating Freekassa payment and sending response.
|
||||
|
||||
Args:
|
||||
message_or_callback: Either a Message or CallbackQuery object
|
||||
db_user: User object
|
||||
db: Database session
|
||||
amount_kopeks: Amount in kopeks
|
||||
edit_message: Whether to edit existing message or send new one
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
amount_rub = amount_kopeks / 100
|
||||
|
||||
# Create payment
|
||||
payment_service = PaymentService()
|
||||
|
||||
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=settings.PAYMENT_SERVICE_NAME,
|
||||
description="Пополнение баланса",
|
||||
)
|
||||
|
||||
result = await payment_service.create_freekassa_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
email=getattr(db_user, "email", None),
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not result:
|
||||
error_text = texts.t(
|
||||
"PAYMENT_CREATE_ERROR",
|
||||
"Не удалось создать платёж. Попробуйте позже.",
|
||||
)
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
error_text,
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
error_text,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
payment_url = result.get("payment_url")
|
||||
display_name = settings.get_freekassa_display_name()
|
||||
|
||||
# Create keyboard with payment button
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"PAY_BUTTON",
|
||||
"💳 Оплатить {amount}₽",
|
||||
).format(amount=f"{amount_rub:.0f}"),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BACK_BUTTON", "◀️ Назад"),
|
||||
callback_data="menu_balance",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
response_text = texts.t(
|
||||
"FREEKASSA_PAYMENT_CREATED",
|
||||
"💳 <b>Оплата через {name}</b>\n\n"
|
||||
"Сумма: <b>{amount}₽</b>\n\n"
|
||||
"Нажмите кнопку ниже для оплаты.\n"
|
||||
"После успешной оплаты баланс будет пополнен автоматически.",
|
||||
).format(name=display_name, amount=f"{amount_rub:.2f}")
|
||||
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Freekassa payment created: user=%s, amount=%s₽",
|
||||
db_user.telegram_id,
|
||||
amount_rub,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_freekassa_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly (called from quick_amount handlers).
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, "restriction_topup", False):
|
||||
reason = (
|
||||
getattr(db_user, "restriction_reason", None)
|
||||
or "Действие ограничено администратором"
|
||||
)
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append(
|
||||
[InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]
|
||||
)
|
||||
keyboard.append(
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Validate amount
|
||||
min_amount = settings.FREEKASSA_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.FREEKASSA_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PAYMENT_AMOUNT_TOO_LOW",
|
||||
"Минимальная сумма пополнения: {min_amount}₽",
|
||||
).format(min_amount=min_amount // 100),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PAYMENT_AMOUNT_TOO_HIGH",
|
||||
"Максимальная сумма пополнения: {max_amount}₽",
|
||||
).format(max_amount=max_amount // 100),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
|
||||
await _create_freekassa_payment_and_respond(
|
||||
message_or_callback=message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=False,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_freekassa_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Start Freekassa top-up process - ask for amount.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, "restriction_topup", False):
|
||||
reason = (
|
||||
getattr(db_user, "restriction_reason", None)
|
||||
or "Действие ограничено администратором"
|
||||
)
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append(
|
||||
[InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]
|
||||
)
|
||||
keyboard.append(
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
return
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="freekassa")
|
||||
|
||||
min_amount = settings.FREEKASSA_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.FREEKASSA_MAX_AMOUNT_KOPEKS // 100
|
||||
display_name = settings.get_freekassa_display_name()
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BACK_BUTTON", "◀️ Назад"),
|
||||
callback_data="menu_balance",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"FREEKASSA_ENTER_AMOUNT",
|
||||
"💳 <b>Пополнение через {name}</b>\n\n"
|
||||
"Введите сумму пополнения в рублях.\n\n"
|
||||
"Минимум: {min_amount}₽\n"
|
||||
"Максимум: {max_amount}₽",
|
||||
).format(
|
||||
name=display_name,
|
||||
min_amount=min_amount,
|
||||
max_amount=f"{max_amount:,}".replace(",", " "),
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_freekassa_custom_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process custom amount input for Freekassa payment.
|
||||
"""
|
||||
data = await state.get_data()
|
||||
if data.get("payment_method") != "freekassa":
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
try:
|
||||
amount_text = message.text.replace(",", ".").replace(" ", "").strip()
|
||||
amount_rubles = float(amount_text)
|
||||
amount_kopeks = int(amount_rubles * 100)
|
||||
except (ValueError, TypeError):
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PAYMENT_INVALID_AMOUNT",
|
||||
"Введите корректную сумму числом.",
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
await process_freekassa_payment_amount(
|
||||
message=message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
state=state,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_freekassa_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process quick amount selection for Freekassa payment.
|
||||
Called when user clicks a predefined amount button.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_freekassa_enabled():
|
||||
await callback.answer(
|
||||
texts.t("FREEKASSA_NOT_AVAILABLE", "Freekassa временно недоступен"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Extract amount from callback data: topup_amount|freekassa|{amount_kopeks}
|
||||
try:
|
||||
parts = callback.data.split("|")
|
||||
if len(parts) >= 3:
|
||||
amount_kopeks = int(parts[2])
|
||||
else:
|
||||
await callback.answer("Invalid callback data", show_alert=True)
|
||||
return
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("Invalid amount", show_alert=True)
|
||||
return
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, "restriction_topup", False):
|
||||
reason = (
|
||||
getattr(db_user, "restriction_reason", None)
|
||||
or "Действие ограничено администратором"
|
||||
)
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append(
|
||||
[InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]
|
||||
)
|
||||
keyboard.append(
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
return
|
||||
|
||||
# Validate amount
|
||||
min_amount = settings.FREEKASSA_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.FREEKASSA_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await callback.answer(
|
||||
texts.t("AMOUNT_TOO_LOW_SHORT", "Сумма слишком мала"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await callback.answer(
|
||||
texts.t("AMOUNT_TOO_HIGH_SHORT", "Сумма слишком велика"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
await state.clear()
|
||||
|
||||
await _create_freekassa_payment_and_respond(
|
||||
message_or_callback=callback.message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=True,
|
||||
)
|
||||
@@ -71,7 +71,7 @@ async def start_heleket_payment(
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
|
||||
+120
-195
@@ -29,6 +29,97 @@ logger = logging.getLogger(__name__)
|
||||
TRANSACTIONS_PER_PAGE = 10
|
||||
|
||||
|
||||
async def route_payment_by_method(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
payment_method: str
|
||||
) -> bool:
|
||||
"""
|
||||
Роутер платежей по методу оплаты.
|
||||
|
||||
Args:
|
||||
message: Сообщение для ответа
|
||||
db_user: Пользователь БД
|
||||
amount_kopeks: Сумма в копейках
|
||||
state: FSM состояние
|
||||
payment_method: Метод оплаты (yookassa, stars, cryptobot и т.д.)
|
||||
|
||||
Returns:
|
||||
True если платеж обработан, False если метод неизвестен
|
||||
"""
|
||||
if payment_method == "stars":
|
||||
from .stars import process_stars_payment_amount
|
||||
await process_stars_payment_amount(message, db_user, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
# Все остальные методы требуют сессию БД
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
if payment_method == "yookassa":
|
||||
from .yookassa import process_yookassa_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method == "yookassa_sbp":
|
||||
from .yookassa import process_yookassa_sbp_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_sbp_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method == "mulenpay":
|
||||
from .mulenpay import process_mulenpay_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_mulenpay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method == "platega":
|
||||
from .platega import process_platega_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_platega_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method == "wata":
|
||||
from .wata import process_wata_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_wata_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method == "pal24":
|
||||
from .pal24 import process_pal24_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_pal24_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method == "cryptobot":
|
||||
from .cryptobot import process_cryptobot_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_cryptobot_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method == "heleket":
|
||||
from .heleket import process_heleket_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_heleket_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method == "cloudpayments":
|
||||
from .cloudpayments import process_cloudpayments_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_cloudpayments_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method == "freekassa":
|
||||
from .freekassa import process_freekassa_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_freekassa_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_quick_amount_buttons(language: str, user: User) -> list:
|
||||
"""
|
||||
Generate quick amount buttons with user-specific pricing and discounts.
|
||||
@@ -40,7 +131,7 @@ def get_quick_amount_buttons(language: str, user: User) -> list:
|
||||
Returns:
|
||||
List of button rows for inline keyboard
|
||||
"""
|
||||
if not settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED or settings.DISABLE_TOPUP_BUTTONS:
|
||||
if not settings.is_quick_amount_buttons_enabled():
|
||||
return []
|
||||
|
||||
from app.localization.texts import get_texts
|
||||
@@ -565,59 +656,7 @@ async def process_topup_amount(
|
||||
await message.answer(f"❌ Максимальная сумма для оплаты через YooKassa: {max_rubles:,.0f} ₽".replace(',', ' '))
|
||||
return
|
||||
|
||||
if payment_method == "stars":
|
||||
from .stars import process_stars_payment_amount
|
||||
await process_stars_payment_amount(message, db_user, amount_kopeks, state)
|
||||
elif payment_method == "yookassa":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .yookassa import process_yookassa_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "yookassa_sbp":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .yookassa import process_yookassa_sbp_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_sbp_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "mulenpay":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .mulenpay import process_mulenpay_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_mulenpay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "platega":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .platega import process_platega_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_platega_payment_amount(
|
||||
message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "wata":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .wata import process_wata_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_wata_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "pal24":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .pal24 import process_pal24_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_pal24_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "cryptobot":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .cryptobot import process_cryptobot_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_cryptobot_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "heleket":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .heleket import process_heleket_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_heleket_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "cloudpayments":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .cloudpayments import process_cloudpayments_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_cloudpayments_amount(message, db_user, db, state)
|
||||
else:
|
||||
if not await route_payment_by_method(message, db_user, amount_kopeks, state, payment_method):
|
||||
await message.answer("Неизвестный способ оплаты")
|
||||
|
||||
except ValueError:
|
||||
@@ -676,91 +715,24 @@ async def handle_quick_amount_selection(
|
||||
"""
|
||||
Обработчик выбора суммы через кнопки быстрого выбора
|
||||
"""
|
||||
# Проверяем, что пользователь в правильном состоянии FSM
|
||||
current_state = await state.get_state()
|
||||
if current_state != BalanceStates.waiting_for_amount:
|
||||
await callback.answer("❌ Сначала выберите способ оплаты", show_alert=True)
|
||||
return
|
||||
|
||||
# Извлекаем сумму из callback_data
|
||||
try:
|
||||
amount_kopeks = int(callback.data.split('_')[-1])
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
|
||||
# Получаем метод оплаты из состояния
|
||||
data = await state.get_data()
|
||||
payment_method = data.get("payment_method", "yookassa")
|
||||
|
||||
# Проверяем, какой метод оплаты был выбран и вызываем соответствующий обработчик
|
||||
if payment_method == "yookassa":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .yookassa import process_yookassa_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "yookassa_sbp":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .yookassa import process_yookassa_sbp_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_sbp_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "mulenpay":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .mulenpay import process_mulenpay_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_mulenpay_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "platega":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .platega import process_platega_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_platega_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "wata":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .wata import process_wata_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_wata_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "pal24":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .pal24 import process_pal24_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_pal24_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "cryptobot":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .cryptobot import process_cryptobot_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_cryptobot_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "heleket":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .heleket import process_heleket_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_heleket_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "cloudpayments":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .cloudpayments import process_cloudpayments_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_cloudpayments_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "stars":
|
||||
from .stars import process_stars_payment_amount
|
||||
|
||||
await process_stars_payment_amount(
|
||||
callback.message, db_user, amount_kopeks, state
|
||||
)
|
||||
else:
|
||||
# Роутим платеж на соответствующий обработчик
|
||||
if not await route_payment_by_method(
|
||||
callback.message, db_user, amount_kopeks, state, payment_method
|
||||
):
|
||||
await callback.answer("❌ Неизвестный способ оплаты", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -789,28 +761,8 @@ async def handle_topup_amount_callback(
|
||||
return
|
||||
|
||||
try:
|
||||
if method == "yookassa":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .yookassa import process_yookassa_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "yookassa_sbp":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .yookassa import process_yookassa_sbp_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_sbp_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "mulenpay":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .mulenpay import process_mulenpay_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_mulenpay_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "platega":
|
||||
# Особые случаи, требующие специальной логики
|
||||
if method == "platega":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .platega import process_platega_payment_amount, start_platega_payment
|
||||
|
||||
@@ -825,51 +777,14 @@ async def handle_topup_amount_callback(
|
||||
else:
|
||||
await state.update_data(platega_pending_amount=amount_kopeks)
|
||||
await start_platega_payment(callback, db_user, state)
|
||||
elif method == "pal24":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .pal24 import process_pal24_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_pal24_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "cryptobot":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .cryptobot import process_cryptobot_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_cryptobot_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "heleket":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .heleket import process_heleket_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_heleket_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "wata":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .wata import process_wata_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_wata_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "cloudpayments":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .cloudpayments import process_cloudpayments_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_cloudpayments_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "stars":
|
||||
from .stars import process_stars_payment_amount
|
||||
await process_stars_payment_amount(
|
||||
callback.message, db_user, amount_kopeks, state
|
||||
)
|
||||
elif method == "tribute":
|
||||
from .tribute import start_tribute_payment
|
||||
await start_tribute_payment(callback, db_user)
|
||||
return
|
||||
else:
|
||||
# Стандартные методы через роутер
|
||||
elif not await route_payment_by_method(
|
||||
callback.message, db_user, amount_kopeks, state, method
|
||||
):
|
||||
await callback.answer("❌ Неизвестный способ оплаты", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -1013,6 +928,16 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
F.data.startswith("topup_amount|cloudpayments|")
|
||||
)
|
||||
|
||||
from .freekassa import start_freekassa_topup, process_freekassa_quick_amount
|
||||
dp.callback_query.register(
|
||||
start_freekassa_topup,
|
||||
F.data == "topup_freekassa"
|
||||
)
|
||||
dp.callback_query.register(
|
||||
process_freekassa_quick_amount,
|
||||
F.data.startswith("topup_amount|freekassa|")
|
||||
)
|
||||
|
||||
from .mulenpay import check_mulenpay_payment_status
|
||||
dp.callback_query.register(
|
||||
check_mulenpay_payment_status,
|
||||
|
||||
@@ -64,7 +64,7 @@ async def start_mulenpay_payment(
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
|
||||
@@ -307,7 +307,7 @@ async def start_pal24_payment(
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
|
||||
@@ -80,7 +80,7 @@ async def _prompt_amount(
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
|
||||
@@ -45,7 +45,7 @@ async def start_stars_payment(
|
||||
return
|
||||
|
||||
# Формируем текст сообщения в зависимости от настройки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
message_text = (
|
||||
f"⭐ <b>Пополнение через Telegram Stars</b>\n\n"
|
||||
f"Выберите сумму пополнения или введите вручную:"
|
||||
@@ -57,7 +57,7 @@ async def start_stars_payment(
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
|
||||
@@ -61,7 +61,7 @@ async def start_wata_payment(
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
|
||||
@@ -51,7 +51,7 @@ async def start_yookassa_payment(
|
||||
max_amount_rub = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
|
||||
|
||||
# Формируем текст сообщения в зависимости от настройки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
message_text = (
|
||||
f"💳 <b>Оплата банковской картой</b>\n\n"
|
||||
f"Выберите сумму пополнения или введите вручную сумму "
|
||||
@@ -67,7 +67,7 @@ async def start_yookassa_payment(
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
@@ -122,7 +122,7 @@ async def start_yookassa_sbp_payment(
|
||||
max_amount_rub = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
|
||||
|
||||
# Формируем текст сообщения в зависимости от настройки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
message_text = (
|
||||
f"🏦 <b>Оплата через СБП</b>\n\n"
|
||||
f"Выберите сумму пополнения или введите вручную сумму "
|
||||
@@ -138,7 +138,7 @@ async def start_yookassa_sbp_payment(
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
|
||||
@@ -1291,7 +1291,7 @@ async def handle_activate_button(
|
||||
server_ids = await get_server_ids_by_uuids(db, connected_squads) if connected_squads else []
|
||||
|
||||
balance = db_user.balance_kopeks
|
||||
available_periods = sorted([int(p) for p in settings.AVAILABLE_SUBSCRIPTION_PERIODS], reverse=True)
|
||||
available_periods = sorted(settings.get_available_subscription_periods(), reverse=True)
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
|
||||
|
||||
@@ -125,6 +125,10 @@ async def process_promocode(
|
||||
"expired": texts.PROMOCODE_EXPIRED,
|
||||
"used": texts.PROMOCODE_USED,
|
||||
"already_used_by_user": texts.PROMOCODE_USED,
|
||||
"not_first_purchase": texts.t(
|
||||
"PROMOCODE_NOT_FIRST_PURCHASE",
|
||||
"❌ Этот промокод доступен только для первой покупки"
|
||||
),
|
||||
"server_error": texts.ERROR
|
||||
}
|
||||
|
||||
|
||||
@@ -27,8 +27,17 @@ async def show_referral_info(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
# Проверяем, включена ли реферальная программа
|
||||
if not settings.is_referral_program_enabled():
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t("REFERRAL_PROGRAM_DISABLED", "Реферальная программа отключена"),
|
||||
show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
summary = await get_user_referral_summary(db, db_user.id)
|
||||
|
||||
bot_username = (await callback.bot.get_me()).username
|
||||
|
||||
@@ -62,6 +62,15 @@ async def start_simple_subscription_purchase(
|
||||
|
||||
device_limit = resolve_simple_subscription_device_limit()
|
||||
|
||||
# При продлении учитываем количество устройств из текущей подписки
|
||||
if current_subscription and settings.is_devices_selection_enabled():
|
||||
current_device_limit = current_subscription.device_limit or device_limit
|
||||
# Модем добавляет +1 к device_limit, но оплачивается отдельно
|
||||
if getattr(current_subscription, 'modem_enabled', False):
|
||||
current_device_limit = max(1, current_device_limit - 1)
|
||||
# Используем максимум из текущего и дефолтного
|
||||
device_limit = max(device_limit, current_device_limit)
|
||||
|
||||
# Подготовим параметры простой подписки
|
||||
subscription_params = {
|
||||
"period_days": settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS,
|
||||
|
||||
+53
-3
@@ -1982,11 +1982,61 @@ async def required_sub_channel_check(
|
||||
language=language,
|
||||
referral_code=referral_code,
|
||||
)
|
||||
await db.refresh(user, ['subscription'])
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=query.from_user.id,
|
||||
text=texts.t("WELCOME_FALLBACK", "Добро пожаловать, {user_name}!").format(user_name=user.full_name),
|
||||
# Показываем главное меню после создания пользователя
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
user.subscription
|
||||
)
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
from app.utils.message_patch import LOGO_PATH
|
||||
from aiogram.types import FSInputFile
|
||||
|
||||
is_admin = settings.is_admin(user.telegram_id)
|
||||
is_moderator = (
|
||||
(not is_admin)
|
||||
and SupportSettingsService.is_moderator(user.telegram_id)
|
||||
)
|
||||
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
keyboard = await get_main_menu_keyboard_async(
|
||||
db=db,
|
||||
user=user,
|
||||
language=user.language,
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
)
|
||||
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
await bot.send_photo(
|
||||
chat_id=query.from_user.id,
|
||||
photo=FSInputFile(LOGO_PATH),
|
||||
caption=menu_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=query.from_user.id,
|
||||
text=menu_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await _send_pinned_message(bot, db, user)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=query.from_user.id,
|
||||
|
||||
@@ -183,13 +183,6 @@ async def handle_change_devices(
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
await callback.answer(
|
||||
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if not subscription or subscription.is_trial:
|
||||
await callback.answer(
|
||||
texts.t("PAID_FEATURE_ONLY", "⚠️ Эта функция доступна только для платных подписок"),
|
||||
@@ -197,6 +190,30 @@ async def handle_change_devices(
|
||||
)
|
||||
return
|
||||
|
||||
# Проверяем тариф подписки
|
||||
tariff = None
|
||||
if subscription.tariff_id:
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
|
||||
# Для тарифов - проверяем разрешено ли изменение устройств
|
||||
tariff_device_price = getattr(tariff, 'device_price_kopeks', None) if tariff else None
|
||||
if tariff:
|
||||
if tariff_device_price is None or tariff_device_price <= 0:
|
||||
await callback.answer(
|
||||
texts.t("TARIFF_DEVICES_DISABLED", "⚠️ Изменение устройств недоступно для вашего тарифа"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
else:
|
||||
# Для обычных подписок проверяем глобальную настройку
|
||||
if not settings.is_devices_selection_enabled():
|
||||
await callback.answer(
|
||||
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
current_devices = subscription.device_limit
|
||||
|
||||
period_hint_days = _get_period_hint_from_subscription(subscription)
|
||||
@@ -206,17 +223,34 @@ async def handle_change_devices(
|
||||
period_hint_days,
|
||||
)
|
||||
|
||||
prompt_text = texts.t(
|
||||
"CHANGE_DEVICES_PROMPT",
|
||||
(
|
||||
"📱 <b>Изменение количества устройств</b>\n\n"
|
||||
"Текущий лимит: {current_devices} устройств\n"
|
||||
"Выберите новое количество устройств:\n\n"
|
||||
"💡 <b>Важно:</b>\n"
|
||||
"• При увеличении - доплата пропорционально оставшемуся времени\n"
|
||||
"• При уменьшении - возврат средств не производится"
|
||||
),
|
||||
).format(current_devices=current_devices)
|
||||
# Для тарифов показываем цену из тарифа
|
||||
if tariff:
|
||||
price_per_device = tariff_device_price
|
||||
price_text = texts.format_price(price_per_device)
|
||||
prompt_text = texts.t(
|
||||
"CHANGE_DEVICES_PROMPT_TARIFF",
|
||||
(
|
||||
"📱 <b>Изменение количества устройств</b>\n\n"
|
||||
"Текущий лимит: {current_devices} устройств\n"
|
||||
"Цена за доп. устройство: {price}/мес\n"
|
||||
"Выберите новое количество устройств:\n\n"
|
||||
"💡 <b>Важно:</b>\n"
|
||||
"• При увеличении - доплата пропорционально оставшемуся времени\n"
|
||||
"• При уменьшении - возврат средств не производится"
|
||||
),
|
||||
).format(current_devices=current_devices, price=price_text)
|
||||
else:
|
||||
prompt_text = texts.t(
|
||||
"CHANGE_DEVICES_PROMPT",
|
||||
(
|
||||
"📱 <b>Изменение количества устройств</b>\n\n"
|
||||
"Текущий лимит: {current_devices} устройств\n"
|
||||
"Выберите новое количество устройств:\n\n"
|
||||
"💡 <b>Важно:</b>\n"
|
||||
"• При увеличении - доплата пропорционально оставшемуся времени\n"
|
||||
"• При уменьшении - возврат средств не производится"
|
||||
),
|
||||
).format(current_devices=current_devices)
|
||||
|
||||
await callback.message.edit_text(
|
||||
prompt_text,
|
||||
@@ -225,6 +259,7 @@ async def handle_change_devices(
|
||||
db_user.language,
|
||||
subscription.end_date,
|
||||
devices_discount_percent,
|
||||
tariff=tariff,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -240,12 +275,30 @@ async def confirm_change_devices(
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
await callback.answer(
|
||||
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
# Проверяем тариф подписки
|
||||
tariff = None
|
||||
if subscription.tariff_id:
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
|
||||
# Для тарифов - проверяем разрешено ли изменение устройств
|
||||
tariff_device_price = getattr(tariff, 'device_price_kopeks', None) if tariff else None
|
||||
if tariff:
|
||||
if tariff_device_price is None or tariff_device_price <= 0:
|
||||
await callback.answer(
|
||||
texts.t("TARIFF_DEVICES_DISABLED", "⚠️ Изменение устройств недоступно для вашего тарифа"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
price_per_device = tariff_device_price
|
||||
else:
|
||||
if not settings.is_devices_selection_enabled():
|
||||
await callback.answer(
|
||||
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
price_per_device = settings.PRICE_PER_DEVICE
|
||||
|
||||
current_devices = subscription.device_limit
|
||||
|
||||
@@ -271,13 +324,16 @@ async def confirm_change_devices(
|
||||
if devices_difference > 0:
|
||||
additional_devices = devices_difference
|
||||
|
||||
if current_devices < settings.DEFAULT_DEVICE_LIMIT:
|
||||
# Для тарифов - все устройства платные (нет бесплатного лимита)
|
||||
if tariff:
|
||||
chargeable_devices = additional_devices
|
||||
elif current_devices < settings.DEFAULT_DEVICE_LIMIT:
|
||||
free_devices = settings.DEFAULT_DEVICE_LIMIT - current_devices
|
||||
chargeable_devices = max(0, additional_devices - free_devices)
|
||||
else:
|
||||
chargeable_devices = additional_devices
|
||||
|
||||
devices_price_per_month = chargeable_devices * settings.PRICE_PER_DEVICE
|
||||
devices_price_per_month = chargeable_devices * price_per_device
|
||||
months_hint = get_remaining_months(subscription.end_date)
|
||||
period_hint_days = months_hint * 30 if months_hint > 0 else None
|
||||
devices_discount_percent = _get_addon_discount_percent_for_user(
|
||||
@@ -937,12 +993,30 @@ async def confirm_add_devices(
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
await callback.answer(
|
||||
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
# Проверяем тариф подписки
|
||||
tariff = None
|
||||
if subscription.tariff_id:
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
|
||||
# Для тарифов - проверяем разрешено ли добавление устройств
|
||||
tariff_device_price = getattr(tariff, 'device_price_kopeks', None) if tariff else None
|
||||
if tariff:
|
||||
if tariff_device_price is None or tariff_device_price <= 0:
|
||||
await callback.answer(
|
||||
texts.t("TARIFF_DEVICES_DISABLED", "⚠️ Добавление устройств недоступно для вашего тарифа"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
price_per_device = tariff_device_price
|
||||
else:
|
||||
if not settings.is_devices_selection_enabled():
|
||||
await callback.answer(
|
||||
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
price_per_device = settings.PRICE_PER_DEVICE
|
||||
|
||||
resume_callback = None
|
||||
|
||||
@@ -956,7 +1030,7 @@ async def confirm_add_devices(
|
||||
)
|
||||
return
|
||||
|
||||
devices_price_per_month = devices_count * settings.PRICE_PER_DEVICE
|
||||
devices_price_per_month = devices_count * price_per_device
|
||||
months_hint = get_remaining_months(subscription.end_date)
|
||||
period_hint_days = months_hint * 30 if months_hint > 0 else None
|
||||
devices_discount_percent = _get_addon_discount_percent_for_user(
|
||||
|
||||
@@ -129,6 +129,10 @@ from app.utils.promo_offer import (
|
||||
build_promo_offer_hint,
|
||||
get_user_active_promo_discount_percent,
|
||||
)
|
||||
from app.handlers.simple_subscription import (
|
||||
_calculate_simple_subscription_price,
|
||||
_get_simple_subscription_payment_keyboard,
|
||||
)
|
||||
|
||||
from .common import _apply_promo_offer_discount, _get_promo_offer_discount_percent, logger, update_traffic_prices
|
||||
from .autopay import (
|
||||
@@ -329,6 +333,17 @@ async def show_subscription_info(
|
||||
else texts.t("SUBSCRIPTION_NO_SERVERS", "Нет серверов")
|
||||
)
|
||||
|
||||
# Получаем название тарифа для режима тарифов
|
||||
tariff_line = ""
|
||||
if settings.is_tariffs_mode() and subscription.tariff_id:
|
||||
try:
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff:
|
||||
tariff_line = f"\n📦 Тариф: {tariff.name}"
|
||||
except Exception as e:
|
||||
logger.warning(f"Ошибка получения тарифа: {e}")
|
||||
|
||||
message_template = texts.t(
|
||||
"SUBSCRIPTION_OVERVIEW_TEMPLATE",
|
||||
"""👤 {full_name}
|
||||
@@ -336,7 +351,7 @@ async def show_subscription_info(
|
||||
📱 Подписка: {status_emoji} {status_display}{warning}
|
||||
|
||||
📱 Информация о подписке
|
||||
🎭 Тип: {subscription_type}
|
||||
🎭 Тип: {subscription_type}{tariff_line}
|
||||
📅 Действует до: {end_date}
|
||||
⏰ Осталось: {time_left}
|
||||
📈 Трафик: {traffic}
|
||||
@@ -366,6 +381,7 @@ async def show_subscription_info(
|
||||
status_display=status_display,
|
||||
warning=warning_text,
|
||||
subscription_type=subscription_type,
|
||||
tariff_line=tariff_line,
|
||||
end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"),
|
||||
time_left=time_left_text,
|
||||
traffic=traffic_used_display,
|
||||
@@ -442,34 +458,74 @@ async def show_trial_offer(
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Получаем параметры триала (из тарифа или из глобальных настроек)
|
||||
trial_days = settings.TRIAL_DURATION_DAYS
|
||||
trial_traffic = settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
trial_device_limit = settings.TRIAL_DEVICE_LIMIT
|
||||
trial_tariff = None
|
||||
trial_server_name = texts.t("TRIAL_SERVER_DEFAULT_NAME", "🎯 Тестовый сервер")
|
||||
|
||||
# Проверяем триальный тариф
|
||||
if settings.is_tariffs_mode():
|
||||
try:
|
||||
from app.database.crud.tariff import get_trial_tariff, get_tariff_by_id as get_tariff
|
||||
|
||||
trial_tariff = await get_trial_tariff(db)
|
||||
if not trial_tariff:
|
||||
trial_tariff_id = settings.get_trial_tariff_id()
|
||||
if trial_tariff_id > 0:
|
||||
trial_tariff = await get_tariff(db, trial_tariff_id)
|
||||
if trial_tariff and not trial_tariff.is_active:
|
||||
trial_tariff = None
|
||||
|
||||
if trial_tariff:
|
||||
trial_traffic = trial_tariff.traffic_limit_gb
|
||||
trial_device_limit = trial_tariff.device_limit
|
||||
tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None)
|
||||
if tariff_trial_days:
|
||||
trial_days = tariff_trial_days
|
||||
logger.info(f"Показываем триал с тарифом {trial_tariff.name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения триального тарифа: {e}")
|
||||
|
||||
try:
|
||||
from app.database.crud.server_squad import get_trial_eligible_server_squads
|
||||
|
||||
trial_squads = await get_trial_eligible_server_squads(db, include_unavailable=True)
|
||||
|
||||
if trial_squads:
|
||||
if len(trial_squads) == 1:
|
||||
trial_server_name = trial_squads[0].display_name
|
||||
else:
|
||||
trial_server_name = texts.t(
|
||||
"TRIAL_SERVER_RANDOM_POOL",
|
||||
"🎲 Случайный из {count} серверов",
|
||||
).format(count=len(trial_squads))
|
||||
# Для тарифа используем его сервера
|
||||
if trial_tariff and trial_tariff.allowed_squads:
|
||||
from app.database.crud.server_squad import get_server_squads_by_uuids
|
||||
tariff_squads = await get_server_squads_by_uuids(db, trial_tariff.allowed_squads)
|
||||
if tariff_squads:
|
||||
if len(tariff_squads) == 1:
|
||||
trial_server_name = tariff_squads[0].display_name
|
||||
else:
|
||||
trial_server_name = texts.t(
|
||||
"TRIAL_SERVER_RANDOM_POOL",
|
||||
"🎲 Случайный из {count} серверов",
|
||||
).format(count=len(tariff_squads))
|
||||
else:
|
||||
logger.warning("Не настроены сквады для выдачи триалов")
|
||||
trial_squads = await get_trial_eligible_server_squads(db, include_unavailable=True)
|
||||
if trial_squads:
|
||||
if len(trial_squads) == 1:
|
||||
trial_server_name = trial_squads[0].display_name
|
||||
else:
|
||||
trial_server_name = texts.t(
|
||||
"TRIAL_SERVER_RANDOM_POOL",
|
||||
"🎲 Случайный из {count} серверов",
|
||||
).format(count=len(trial_squads))
|
||||
else:
|
||||
logger.warning("Не настроены сквады для выдачи триалов")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения триального сервера: {e}")
|
||||
|
||||
trial_device_limit = settings.TRIAL_DEVICE_LIMIT
|
||||
if not settings.is_devices_selection_enabled():
|
||||
forced_limit = settings.get_disabled_mode_device_limit()
|
||||
if forced_limit is not None:
|
||||
trial_device_limit = forced_limit
|
||||
|
||||
devices_line = ""
|
||||
if settings.is_devices_selection_enabled():
|
||||
if settings.is_devices_selection_enabled() or trial_tariff:
|
||||
devices_line_template = texts.t(
|
||||
"TRIAL_AVAILABLE_DEVICES_LINE",
|
||||
"\n📱 <b>Устройства:</b> {devices} шт.",
|
||||
@@ -488,8 +544,8 @@ async def show_trial_offer(
|
||||
).format(price=settings.format_price(trial_price))
|
||||
|
||||
trial_text = texts.TRIAL_AVAILABLE.format(
|
||||
days=settings.TRIAL_DURATION_DAYS,
|
||||
traffic=texts.format_traffic(settings.TRIAL_TRAFFIC_LIMIT_GB),
|
||||
days=trial_days,
|
||||
traffic=texts.format_traffic(trial_traffic),
|
||||
devices=trial_device_limit if trial_device_limit is not None else "",
|
||||
devices_line=devices_line,
|
||||
server_name=trial_server_name,
|
||||
@@ -664,10 +720,49 @@ async def activate_trial(
|
||||
if not settings.is_devices_selection_enabled():
|
||||
forced_devices = settings.get_disabled_mode_device_limit()
|
||||
|
||||
# Проверяем, настроен ли триальный тариф для режима тарифов
|
||||
trial_tariff = None
|
||||
trial_traffic_limit = None
|
||||
trial_device_limit = forced_devices
|
||||
trial_squads = None
|
||||
tariff_id_for_trial = None
|
||||
trial_duration = None # None = использовать TRIAL_DURATION_DAYS
|
||||
|
||||
if settings.is_tariffs_mode():
|
||||
try:
|
||||
from app.database.crud.tariff import get_tariff_by_id, get_trial_tariff
|
||||
|
||||
# Сначала проверяем тариф из БД с флагом is_trial_available
|
||||
trial_tariff = await get_trial_tariff(db)
|
||||
|
||||
# Если не найден в БД, проверяем настройку TRIAL_TARIFF_ID
|
||||
if not trial_tariff:
|
||||
trial_tariff_id = settings.get_trial_tariff_id()
|
||||
if trial_tariff_id > 0:
|
||||
trial_tariff = await get_tariff_by_id(db, trial_tariff_id)
|
||||
if trial_tariff and not trial_tariff.is_active:
|
||||
trial_tariff = None
|
||||
|
||||
if trial_tariff:
|
||||
trial_traffic_limit = trial_tariff.traffic_limit_gb
|
||||
trial_device_limit = trial_tariff.device_limit
|
||||
trial_squads = trial_tariff.allowed_squads or []
|
||||
tariff_id_for_trial = trial_tariff.id
|
||||
tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None)
|
||||
if tariff_trial_days:
|
||||
trial_duration = tariff_trial_days
|
||||
logger.info(f"Используем триальный тариф {trial_tariff.name} (ID: {trial_tariff.id})")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения триального тарифа: {e}")
|
||||
|
||||
subscription = await create_trial_subscription(
|
||||
db,
|
||||
db_user.id,
|
||||
device_limit=forced_devices,
|
||||
duration_days=trial_duration,
|
||||
device_limit=trial_device_limit,
|
||||
traffic_limit_gb=trial_traffic_limit,
|
||||
connected_squads=trial_squads,
|
||||
tariff_id=tariff_id_for_trial,
|
||||
)
|
||||
|
||||
await db.refresh(db_user)
|
||||
@@ -1044,6 +1139,12 @@ async def start_subscription_purchase(
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверяем режим продаж - если tariffs, перенаправляем на выбор тарифов
|
||||
if settings.is_tariffs_mode():
|
||||
from .tariff_purchase import show_tariffs_list
|
||||
await show_tariffs_list(callback, db_user, db, state)
|
||||
return
|
||||
|
||||
keyboard = get_subscription_period_keyboard(db_user.language, db_user)
|
||||
prompt_text = await _build_subscription_period_prompt(db_user, texts, db)
|
||||
|
||||
@@ -1319,6 +1420,35 @@ async def handle_extend_subscription(
|
||||
await callback.answer("⚠ Продление доступно только для платных подписок", show_alert=True)
|
||||
return
|
||||
|
||||
# В режиме тарифов проверяем наличие tariff_id
|
||||
if settings.is_tariffs_mode():
|
||||
if subscription.tariff_id:
|
||||
# У подписки есть тариф - перенаправляем на продление по тарифу
|
||||
from .tariff_purchase import show_tariff_extend
|
||||
await show_tariff_extend(callback, db_user, db)
|
||||
return
|
||||
else:
|
||||
# У подписки нет тарифа - предлагаем выбрать тариф
|
||||
await callback.message.edit_text(
|
||||
"📦 <b>Выберите тариф для продления</b>\n\n"
|
||||
"Ваша текущая подписка была создана до введения тарифов.\n"
|
||||
"Для продления необходимо выбрать один из доступных тарифов.\n\n"
|
||||
"⚠️ Ваша текущая подписка продолжит действовать до окончания срока.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text="📦 Выбрать тариф",
|
||||
callback_data="tariff_switch"
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data="menu_subscription"
|
||||
)]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
|
||||
available_periods = settings.get_available_renewal_periods()
|
||||
@@ -2825,6 +2955,12 @@ async def handle_subscription_settings(
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
# Получаем тариф подписки если есть
|
||||
tariff = None
|
||||
if subscription and subscription.tariff_id:
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
|
||||
if not subscription or subscription.is_trial:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
@@ -2880,7 +3016,7 @@ async def handle_subscription_settings(
|
||||
|
||||
await callback.message.edit_text(
|
||||
settings_text,
|
||||
reply_markup=get_updated_subscription_settings_keyboard(db_user.language, show_countries),
|
||||
reply_markup=get_updated_subscription_settings_keyboard(db_user.language, show_countries, tariff=tariff),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
@@ -3891,6 +4027,10 @@ def register_handlers(dp: Dispatcher):
|
||||
from .modem import register_modem_handlers
|
||||
register_modem_handlers(dp)
|
||||
|
||||
# Регистрируем обработчики покупки по тарифам
|
||||
from .tariff_purchase import register_tariff_purchase_handlers
|
||||
register_tariff_purchase_handlers(dp)
|
||||
|
||||
# Регистрируем обработчик для простой покупки
|
||||
dp.callback_query.register(
|
||||
handle_simple_subscription_purchase,
|
||||
@@ -4051,86 +4191,6 @@ async def handle_simple_subscription_purchase(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
|
||||
|
||||
async def _calculate_simple_subscription_price(
|
||||
db: AsyncSession,
|
||||
params: dict,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
resolved_squad_uuid: Optional[str] = None,
|
||||
) -> Tuple[int, Dict[str, Any]]:
|
||||
"""Рассчитывает цену простой подписки."""
|
||||
|
||||
resolved_uuids = [resolved_squad_uuid] if resolved_squad_uuid else None
|
||||
return await compute_simple_subscription_price(
|
||||
db,
|
||||
params,
|
||||
user=user,
|
||||
resolved_squad_uuids=resolved_uuids,
|
||||
)
|
||||
|
||||
|
||||
def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyboardMarkup:
|
||||
"""Создает клавиатуру с методами оплаты для простой подписки."""
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
# Добавляем доступные методы оплаты
|
||||
if settings.TELEGRAM_STARS_ENABLED:
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text="⭐ Telegram Stars",
|
||||
callback_data="simple_subscription_stars"
|
||||
)])
|
||||
|
||||
if settings.is_yookassa_enabled():
|
||||
yookassa_methods = []
|
||||
if settings.YOOKASSA_SBP_ENABLED:
|
||||
yookassa_methods.append(types.InlineKeyboardButton(
|
||||
text="🏦 YooKassa (СБП)",
|
||||
callback_data="simple_subscription_yookassa_sbp"
|
||||
))
|
||||
yookassa_methods.append(types.InlineKeyboardButton(
|
||||
text="💳 YooKassa (Карта)",
|
||||
callback_data="simple_subscription_yookassa"
|
||||
))
|
||||
if yookassa_methods:
|
||||
keyboard.append(yookassa_methods)
|
||||
|
||||
if settings.is_cryptobot_enabled():
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text="🪙 CryptoBot",
|
||||
callback_data="simple_subscription_cryptobot"
|
||||
)])
|
||||
|
||||
if settings.is_mulenpay_enabled():
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text=f"💳 {mulenpay_name}",
|
||||
callback_data="simple_subscription_mulenpay"
|
||||
)])
|
||||
|
||||
if settings.is_pal24_enabled():
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text="💳 PayPalych",
|
||||
callback_data="simple_subscription_pal24"
|
||||
)])
|
||||
|
||||
if settings.is_wata_enabled():
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text="💳 WATA",
|
||||
callback_data="simple_subscription_wata"
|
||||
)])
|
||||
|
||||
# Кнопка назад
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data="subscription_purchase"
|
||||
)])
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
async def _extend_existing_subscription(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -107,6 +107,17 @@ async def handle_add_traffic(
|
||||
)
|
||||
return
|
||||
|
||||
# В режиме тарифов докупка трафика недоступна
|
||||
if settings.is_tariffs_mode():
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"TARIFF_TRAFFIC_TOPUP_DISABLED",
|
||||
"⚠️ В режиме тарифов докупка трафика недоступна",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if settings.is_traffic_topup_blocked():
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
|
||||
@@ -24,10 +24,16 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_MAIN_TARIFFS", "📦 Тарифы"),
|
||||
callback_data="admin_tariffs",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_MAIN_PRICING", "💰 Цены"),
|
||||
callback_data="admin_pricing",
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_MAIN_PROMO_STATS", "💰 Промокоды/Статистика"),
|
||||
callback_data="admin_submenu_promo",
|
||||
|
||||
+68
-24
@@ -807,10 +807,10 @@ def get_happ_download_link_keyboard(language: str, link: str) -> InlineKeyboardM
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def get_back_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||
def get_back_keyboard(language: str = DEFAULT_LANGUAGE, callback_data: str = "back_to_menu") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data=callback_data)]
|
||||
])
|
||||
|
||||
|
||||
@@ -993,6 +993,14 @@ def get_subscription_keyboard(
|
||||
callback_data="subscription_settings",
|
||||
)
|
||||
])
|
||||
# Кнопка смены тарифа для режима тарифов
|
||||
if settings.is_tariffs_mode() and subscription:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CHANGE_TARIFF_BUTTON", "📦 Сменить тариф"),
|
||||
callback_data="tariff_switch"
|
||||
)
|
||||
])
|
||||
# Кнопка докупки трафика для платных подписок
|
||||
if (
|
||||
settings.is_traffic_topup_enabled()
|
||||
@@ -1429,6 +1437,16 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
])
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_freekassa_enabled():
|
||||
freekassa_name = settings.get_freekassa_display_name()
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_FREEKASSA", f"💳 {freekassa_name}"),
|
||||
callback_data=_build_callback("freekassa")
|
||||
)
|
||||
])
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_support_topup_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
@@ -1773,28 +1791,37 @@ def get_change_devices_keyboard(
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
subscription_end_date: datetime = None,
|
||||
discount_percent: int = 0,
|
||||
tariff=None, # Тариф для цены за устройство
|
||||
) -> InlineKeyboardMarkup:
|
||||
from app.utils.pricing_utils import get_remaining_months
|
||||
from app.config import settings
|
||||
texts = get_texts(language)
|
||||
|
||||
|
||||
months_multiplier = 1
|
||||
period_text = ""
|
||||
if subscription_end_date:
|
||||
months_multiplier = get_remaining_months(subscription_end_date)
|
||||
if months_multiplier > 1:
|
||||
period_text = f" (за {months_multiplier} мес)"
|
||||
|
||||
device_price_per_month = settings.PRICE_PER_DEVICE
|
||||
|
||||
|
||||
# Используем цену из тарифа если есть, иначе глобальную настройку
|
||||
tariff_device_price = getattr(tariff, 'device_price_kopeks', None) if tariff else None
|
||||
if tariff and tariff_device_price:
|
||||
device_price_per_month = tariff_device_price
|
||||
# Для тарифов все устройства платные (нет бесплатного лимита)
|
||||
default_device_limit = 0
|
||||
else:
|
||||
device_price_per_month = settings.PRICE_PER_DEVICE
|
||||
default_device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
buttons = []
|
||||
|
||||
min_devices = 1
|
||||
|
||||
min_devices = 1
|
||||
max_devices = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else 20
|
||||
|
||||
|
||||
start_range = max(1, min(current_devices - 3, max_devices - 6))
|
||||
end_range = min(max_devices + 1, max(current_devices + 4, 7))
|
||||
|
||||
|
||||
for devices_count in range(start_range, end_range):
|
||||
if devices_count == current_devices:
|
||||
emoji = "✅"
|
||||
@@ -1803,11 +1830,11 @@ def get_change_devices_keyboard(
|
||||
elif devices_count > current_devices:
|
||||
emoji = "➕"
|
||||
additional_devices = devices_count - current_devices
|
||||
|
||||
current_chargeable = max(0, current_devices - settings.DEFAULT_DEVICE_LIMIT)
|
||||
new_chargeable = max(0, devices_count - settings.DEFAULT_DEVICE_LIMIT)
|
||||
|
||||
current_chargeable = max(0, current_devices - default_device_limit)
|
||||
new_chargeable = max(0, devices_count - default_device_limit)
|
||||
chargeable_devices = new_chargeable - current_chargeable
|
||||
|
||||
|
||||
if chargeable_devices > 0:
|
||||
price_per_month = chargeable_devices * device_price_per_month
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
@@ -1829,19 +1856,19 @@ def get_change_devices_keyboard(
|
||||
emoji = "➖"
|
||||
action_text = ""
|
||||
price_text = " (без возврата)"
|
||||
|
||||
|
||||
button_text = f"{emoji} {devices_count} устр.{action_text}{price_text}"
|
||||
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text=button_text, callback_data=f"change_devices_{devices_count}")
|
||||
])
|
||||
|
||||
|
||||
if current_devices < start_range or current_devices >= end_range:
|
||||
current_button = f"✅ {current_devices} устр. (текущее)"
|
||||
buttons.insert(0, [
|
||||
InlineKeyboardButton(text=current_button, callback_data=f"change_devices_{current_devices}")
|
||||
])
|
||||
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
@@ -2392,18 +2419,25 @@ def get_devices_management_keyboard(
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_updated_subscription_settings_keyboard(language: str = DEFAULT_LANGUAGE, show_countries_management: bool = True) -> InlineKeyboardMarkup:
|
||||
def get_updated_subscription_settings_keyboard(
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
show_countries_management: bool = True,
|
||||
tariff=None, # Тариф подписки (если есть - ограничиваем настройки)
|
||||
) -> InlineKeyboardMarkup:
|
||||
from app.config import settings
|
||||
|
||||
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
if show_countries_management:
|
||||
# Если подписка на тарифе - отключаем страны, модем, трафик
|
||||
has_tariff = tariff is not None
|
||||
|
||||
if show_countries_management and not has_tariff:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.t("ADD_COUNTRIES_BUTTON", "🌐 Добавить страны"), callback_data="subscription_add_countries")
|
||||
])
|
||||
|
||||
if settings.is_traffic_selectable():
|
||||
if settings.is_traffic_selectable() and not has_tariff:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.t("RESET_TRAFFIC_BUTTON", "🔄 Сбросить трафик"), callback_data="subscription_reset_traffic")
|
||||
])
|
||||
@@ -2411,7 +2445,17 @@ def get_updated_subscription_settings_keyboard(language: str = DEFAULT_LANGUAGE,
|
||||
InlineKeyboardButton(text=texts.t("SWITCH_TRAFFIC_BUTTON", "🔄 Переключить трафик"), callback_data="subscription_switch_traffic")
|
||||
])
|
||||
|
||||
if settings.is_devices_selection_enabled():
|
||||
# Устройства: для тарифов - только если указана цена за устройство
|
||||
if has_tariff:
|
||||
tariff_device_price = getattr(tariff, 'device_price_kopeks', None)
|
||||
if tariff_device_price is not None and tariff_device_price > 0:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CHANGE_DEVICES_BUTTON", "📱 Изменить устройства"),
|
||||
callback_data="subscription_change_devices"
|
||||
)
|
||||
])
|
||||
elif settings.is_devices_selection_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CHANGE_DEVICES_BUTTON", "📱 Изменить устройства"),
|
||||
@@ -2419,7 +2463,7 @@ def get_updated_subscription_settings_keyboard(language: str = DEFAULT_LANGUAGE,
|
||||
)
|
||||
])
|
||||
|
||||
if settings.is_modem_enabled():
|
||||
if settings.is_modem_enabled() and not has_tariff:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MODEM_BUTTON", "📡 Модем"),
|
||||
|
||||
@@ -666,6 +666,7 @@
|
||||
"ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Публичная оферта",
|
||||
"ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Управление Remnawave, мониторингом и другими настройками:",
|
||||
"ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **Настройки системы**\n\n",
|
||||
"ADMIN_SETTINGS_TARIFFS": "📦 Тарифы",
|
||||
"ADMIN_SQUAD_ADD_ALL": "👥 Добавить всех пользователей",
|
||||
"ADMIN_SQUAD_DELETE": "🗑️ Удалить сквад",
|
||||
"ADMIN_SQUAD_EDIT": "✏️ Редактировать",
|
||||
@@ -940,6 +941,7 @@
|
||||
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n ✅ Количество устройств уменьшено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n ℹ️ Возврат средств не производится\n ",
|
||||
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n ✅ Количество устройств увеличено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n 💰 Списано: {amount}\n ",
|
||||
"CHANGE_DEVICES_TITLE": "📱 Изменение количества устройств",
|
||||
"CHANGE_TARIFF_BUTTON": "📦 Сменить тариф",
|
||||
"CHANNEL_CHECK_BUTTON": "✅ Я подписался",
|
||||
"CHANNEL_REQUIRED_TEXT": "🔒 Для использования бота подпишитесь на новостной канал, а затем нажмите кнопку ниже.",
|
||||
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Подписаться",
|
||||
|
||||
@@ -9,7 +9,7 @@ from aiogram.enums import ChatMemberStatus
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.database.crud.campaign import get_campaign_by_start_parameter
|
||||
from app.database.crud.subscription import deactivate_subscription
|
||||
from app.database.crud.subscription import deactivate_subscription, reactivate_subscription
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
from app.database.models import SubscriptionStatus
|
||||
from app.keyboards.inline import get_channel_sub_keyboard
|
||||
@@ -104,12 +104,15 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
member = await bot.get_chat_member(chat_id=channel_id, user_id=telegram_id)
|
||||
|
||||
if member.status in self.GOOD_MEMBER_STATUS:
|
||||
# Реактивируем подписку если была отключена из-за отписки от канала
|
||||
if telegram_id and (settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE or settings.CHANNEL_REQUIRED_FOR_ALL):
|
||||
await self._reactivate_subscription_on_subscribe(telegram_id, bot)
|
||||
return await handler(event, data)
|
||||
elif member.status in self.BAD_MEMBER_STATUS:
|
||||
logger.info(f"❌ Пользователь {telegram_id} не подписан на канал (статус: {member.status})")
|
||||
|
||||
if telegram_id and (settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE or settings.CHANNEL_REQUIRED_FOR_ALL):
|
||||
await self._deactivate_subscription_on_unsubscribe(telegram_id)
|
||||
await self._deactivate_subscription_on_unsubscribe(telegram_id, bot, channel_link)
|
||||
|
||||
await self._capture_start_payload(state, event, bot)
|
||||
|
||||
@@ -253,7 +256,9 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
finally:
|
||||
break
|
||||
|
||||
async def _deactivate_subscription_on_unsubscribe(self, telegram_id: int) -> None:
|
||||
async def _deactivate_subscription_on_unsubscribe(
|
||||
self, telegram_id: int, bot: Bot, channel_link: Optional[str]
|
||||
) -> None:
|
||||
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
|
||||
logger.debug(
|
||||
"ℹ️ Пропускаем деактивацию подписки пользователя %s: отключение при отписке выключено",
|
||||
@@ -308,6 +313,24 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
user.remnawave_uuid,
|
||||
api_error,
|
||||
)
|
||||
|
||||
# Уведомляем пользователя о деактивации
|
||||
try:
|
||||
texts = get_texts(user.language if user.language else DEFAULT_LANGUAGE)
|
||||
notification_text = texts.t(
|
||||
"SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE",
|
||||
"🚫 Ваша подписка приостановлена, так как вы отписались от канала.\n\n"
|
||||
"Подпишитесь на канал снова, чтобы восстановить доступ к VPN."
|
||||
)
|
||||
channel_kb = get_channel_sub_keyboard(channel_link, language=user.language)
|
||||
await bot.send_message(telegram_id, notification_text, reply_markup=channel_kb)
|
||||
logger.info(f"📨 Уведомление о деактивации отправлено пользователю {telegram_id}")
|
||||
except Exception as notify_error:
|
||||
logger.error(
|
||||
"❌ Не удалось отправить уведомление о деактивации пользователю %s: %s",
|
||||
telegram_id,
|
||||
notify_error,
|
||||
)
|
||||
except Exception as db_error:
|
||||
logger.error(
|
||||
"❌ Ошибка деактивации подписки пользователя %s после отписки: %s",
|
||||
@@ -317,6 +340,77 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
finally:
|
||||
break
|
||||
|
||||
async def _reactivate_subscription_on_subscribe(self, telegram_id: int, bot: Bot) -> None:
|
||||
"""Реактивация подписки после повторной подписки на канал.
|
||||
|
||||
Вызывается только если подписка в статусе DISABLED.
|
||||
"""
|
||||
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
|
||||
return
|
||||
|
||||
async for db in get_db():
|
||||
try:
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
if not user or not user.subscription:
|
||||
break
|
||||
|
||||
subscription = user.subscription
|
||||
|
||||
# Реактивируем только DISABLED подписки (деактивированные из-за отписки)
|
||||
# Тихо выходим если подписка не требует реактивации — без логов
|
||||
if subscription.status != SubscriptionStatus.DISABLED.value:
|
||||
break
|
||||
|
||||
# Проверяем что подписка ещё не истекла
|
||||
from datetime import datetime
|
||||
if subscription.end_date and subscription.end_date <= datetime.utcnow():
|
||||
break
|
||||
|
||||
# Реактивируем в БД
|
||||
await reactivate_subscription(db, subscription)
|
||||
sub_type = "Триальная" if subscription.is_trial else "Платная"
|
||||
logger.info(
|
||||
"✅ %s подписка пользователя %s реактивирована после подписки на канал",
|
||||
sub_type,
|
||||
telegram_id,
|
||||
)
|
||||
|
||||
# Включаем в RemnaWave
|
||||
if user.remnawave_uuid:
|
||||
service = SubscriptionService()
|
||||
try:
|
||||
await service.enable_remnawave_user(user.remnawave_uuid)
|
||||
except Exception as api_error:
|
||||
logger.error(
|
||||
"❌ Не удалось включить пользователя RemnaWave %s: %s",
|
||||
user.remnawave_uuid,
|
||||
api_error,
|
||||
)
|
||||
|
||||
# Уведомляем пользователя о реактивации
|
||||
try:
|
||||
texts = get_texts(user.language if user.language else DEFAULT_LANGUAGE)
|
||||
notification_text = texts.t(
|
||||
"SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE",
|
||||
"✅ Ваша подписка восстановлена!\n\n"
|
||||
"Спасибо, что подписались на канал. VPN снова работает."
|
||||
)
|
||||
await bot.send_message(telegram_id, notification_text)
|
||||
except Exception as notify_error:
|
||||
logger.warning(
|
||||
"Не удалось отправить уведомление о реактивации пользователю %s: %s",
|
||||
telegram_id,
|
||||
notify_error,
|
||||
)
|
||||
except Exception as db_error:
|
||||
logger.error(
|
||||
"❌ Ошибка реактивации подписки пользователя %s: %s",
|
||||
telegram_id,
|
||||
db_error,
|
||||
)
|
||||
finally:
|
||||
break
|
||||
|
||||
@staticmethod
|
||||
async def _deny_message(
|
||||
event: TelegramObject,
|
||||
|
||||
@@ -1364,7 +1364,8 @@ class AdminNotificationService:
|
||||
update_types = {
|
||||
"traffic": ("📊 ИЗМЕНЕНИЕ ТРАФИКА", "трафик"),
|
||||
"devices": ("📱 ИЗМЕНЕНИЕ УСТРОЙСТВ", "количество устройств"),
|
||||
"servers": ("🌐 ИЗМЕНЕНИЕ СЕРВЕРОВ", "серверы")
|
||||
"servers": ("🌐 ИЗМЕНЕНИЕ СЕРВЕРОВ", "серверы"),
|
||||
"modem": ("📡 ИЗМЕНЕНИЕ МОДЕМА", "модем")
|
||||
}
|
||||
|
||||
title, param_name = update_types.get(update_type, ("⚙️ ИЗМЕНЕНИЕ ПОДПИСКИ", "параметры"))
|
||||
@@ -1449,6 +1450,8 @@ class AdminNotificationService:
|
||||
if isinstance(value, list):
|
||||
return f"{len(value)} серверов"
|
||||
return str(value)
|
||||
elif update_type == "modem":
|
||||
return "✅ Включён" if value else "❌ Выключен"
|
||||
return str(value)
|
||||
|
||||
async def send_bulk_ban_notification(
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from datetime import datetime, timedelta
|
||||
import aiohttp
|
||||
|
||||
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.database.crud.campaign import record_campaign_registration
|
||||
from app.database.crud.subscription import (
|
||||
create_trial_subscription,
|
||||
create_paid_subscription,
|
||||
get_subscription_by_user_id,
|
||||
)
|
||||
from app.database.crud.user import add_user_balance
|
||||
@@ -141,7 +141,7 @@ class AdvertisingCampaignService:
|
||||
|
||||
squad_uuid = squads[0] if squads else None
|
||||
|
||||
new_subscription = await create_trial_subscription(
|
||||
new_subscription = await create_paid_subscription(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
duration_days=duration_days,
|
||||
@@ -150,7 +150,6 @@ class AdvertisingCampaignService:
|
||||
connected_squads=squads,
|
||||
update_server_counters=True,
|
||||
is_trial=True,
|
||||
squad_uuid=squad_uuid,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.webhook_service import webhook_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EventEmitter:
|
||||
"""Event emitter для отслеживания и распространения событий системы."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._listeners: dict[str, list[Callable]] = {}
|
||||
self._websocket_connections: set[Any] = set()
|
||||
|
||||
def on(self, event_type: str, callback: Callable) -> None:
|
||||
"""Подписаться на событие."""
|
||||
if event_type not in self._listeners:
|
||||
self._listeners[event_type] = []
|
||||
self._listeners[event_type].append(callback)
|
||||
|
||||
def off(self, event_type: str, callback: Callable) -> None:
|
||||
"""Отписаться от события."""
|
||||
if event_type in self._listeners:
|
||||
try:
|
||||
self._listeners[event_type].remove(callback)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def register_websocket(self, websocket: Any) -> None:
|
||||
"""Зарегистрировать WebSocket подключение."""
|
||||
self._websocket_connections.add(websocket)
|
||||
logger.debug("WebSocket connection registered. Total: %d", len(self._websocket_connections))
|
||||
|
||||
def unregister_websocket(self, websocket: Any) -> None:
|
||||
"""Отменить регистрацию WebSocket подключения."""
|
||||
self._websocket_connections.discard(websocket)
|
||||
logger.debug("WebSocket connection unregistered. Total: %d", len(self._websocket_connections))
|
||||
|
||||
async def emit(
|
||||
self,
|
||||
event_type: str,
|
||||
payload: dict[str, Any],
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> None:
|
||||
"""Отправить событие всем подписчикам."""
|
||||
event_data = {
|
||||
"type": event_type,
|
||||
"payload": payload,
|
||||
"timestamp": str(datetime.utcnow()),
|
||||
}
|
||||
|
||||
# Вызываем локальные слушатели
|
||||
if event_type in self._listeners:
|
||||
for callback in self._listeners[event_type]:
|
||||
try:
|
||||
if asyncio.iscoroutinefunction(callback):
|
||||
await callback(event_data)
|
||||
else:
|
||||
callback(event_data)
|
||||
except Exception as error:
|
||||
logger.exception("Error in event listener for %s: %s", event_type, error)
|
||||
|
||||
# Отправляем через WebSocket
|
||||
await self._broadcast_to_websockets(event_data)
|
||||
|
||||
# Отправляем webhooks
|
||||
if db:
|
||||
await webhook_service.send_webhook(db, event_type, payload)
|
||||
|
||||
async def _broadcast_to_websockets(self, event_data: dict[str, Any]) -> None:
|
||||
"""Отправить событие всем подключенным WebSocket клиентам."""
|
||||
if not self._websocket_connections:
|
||||
return
|
||||
|
||||
disconnected = set()
|
||||
message = json.dumps(event_data, default=str, ensure_ascii=False)
|
||||
|
||||
for ws in self._websocket_connections:
|
||||
try:
|
||||
await ws.send_text(message)
|
||||
except Exception as error:
|
||||
logger.warning("Failed to send WebSocket message: %s", error)
|
||||
disconnected.add(ws)
|
||||
|
||||
# Удаляем отключенные соединения
|
||||
for ws in disconnected:
|
||||
self.unregister_websocket(ws)
|
||||
|
||||
|
||||
# Глобальный экземпляр event emitter
|
||||
event_emitter = EventEmitter()
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Сервис для работы с API Freekassa."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, Set
|
||||
|
||||
import aiohttp
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# IP-адреса Freekassa для проверки webhook
|
||||
FREEKASSA_IPS: Set[str] = {
|
||||
"168.119.157.136",
|
||||
"168.119.60.227",
|
||||
"178.154.197.79",
|
||||
"51.250.54.238",
|
||||
}
|
||||
|
||||
API_BASE_URL = "https://api.fk.life/v1"
|
||||
|
||||
|
||||
class FreekassaService:
|
||||
"""Сервис для работы с API Freekassa."""
|
||||
|
||||
def __init__(self):
|
||||
self._shop_id: Optional[int] = None
|
||||
self._api_key: Optional[str] = None
|
||||
self._secret1: Optional[str] = None
|
||||
self._secret2: Optional[str] = None
|
||||
|
||||
@property
|
||||
def shop_id(self) -> int:
|
||||
if self._shop_id is None:
|
||||
self._shop_id = settings.FREEKASSA_SHOP_ID
|
||||
return self._shop_id or 0
|
||||
|
||||
@property
|
||||
def api_key(self) -> str:
|
||||
if self._api_key is None:
|
||||
self._api_key = settings.FREEKASSA_API_KEY
|
||||
return self._api_key or ""
|
||||
|
||||
@property
|
||||
def secret1(self) -> str:
|
||||
if self._secret1 is None:
|
||||
self._secret1 = settings.FREEKASSA_SECRET_WORD_1
|
||||
return self._secret1 or ""
|
||||
|
||||
@property
|
||||
def secret2(self) -> str:
|
||||
if self._secret2 is None:
|
||||
self._secret2 = settings.FREEKASSA_SECRET_WORD_2
|
||||
return self._secret2 or ""
|
||||
|
||||
def _generate_api_signature_hmac(self, params: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Генерирует подпись для API запроса (HMAC-SHA256).
|
||||
Используется для API методов (создание заказа и т.д.)
|
||||
"""
|
||||
# Исключаем signature из параметров и сортируем по ключу
|
||||
sign_data = {k: v for k, v in params.items() if k != "signature"}
|
||||
sorted_items = sorted(sign_data.items())
|
||||
|
||||
# Формируем строку: значения через |
|
||||
msg = "|".join(str(v) for _, v in sorted_items)
|
||||
|
||||
# HMAC-SHA256
|
||||
return hmac.new(
|
||||
self.api_key.encode("utf-8"),
|
||||
msg.encode("utf-8"),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
def _generate_api_signature(self, params: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Генерирует подпись для API запроса.
|
||||
Для новых API методов используется HMAC-SHA256.
|
||||
"""
|
||||
return self._generate_api_signature_hmac(params)
|
||||
|
||||
def generate_form_signature(
|
||||
self, amount: float, currency: str, order_id: str
|
||||
) -> str:
|
||||
"""
|
||||
Генерирует подпись для платежной формы.
|
||||
Формат: MD5(shop_id:amount:secret1:currency:order_id)
|
||||
"""
|
||||
# Приводим amount к int, если это целое число
|
||||
final_amount = int(amount) if float(amount).is_integer() else amount
|
||||
sign_string = f"{self.shop_id}:{final_amount}:{self.secret1}:{currency}:{order_id}"
|
||||
return hashlib.md5(sign_string.encode()).hexdigest()
|
||||
|
||||
def verify_webhook_signature(
|
||||
self, shop_id: int, amount: float, order_id: str, sign: str
|
||||
) -> bool:
|
||||
"""
|
||||
Проверяет подпись webhook уведомления.
|
||||
Формат: MD5(shop_id:amount:secret2:order_id)
|
||||
"""
|
||||
# Приводим amount к int, если это целое число
|
||||
final_amount = int(amount) if float(amount).is_integer() else amount
|
||||
expected_sign = hashlib.md5(
|
||||
f"{shop_id}:{final_amount}:{self.secret2}:{order_id}".encode()
|
||||
).hexdigest()
|
||||
return sign.lower() == expected_sign.lower()
|
||||
|
||||
def verify_webhook_ip(self, ip: str) -> bool:
|
||||
"""Проверяет, что IP входит в разрешенный список Freekassa."""
|
||||
return ip in FREEKASSA_IPS
|
||||
|
||||
def build_payment_url(
|
||||
self,
|
||||
order_id: str,
|
||||
amount: float,
|
||||
currency: str = "RUB",
|
||||
email: Optional[str] = None,
|
||||
phone: Optional[str] = None,
|
||||
payment_system_id: Optional[int] = None,
|
||||
lang: str = "ru",
|
||||
) -> str:
|
||||
"""
|
||||
Формирует URL для перенаправления на оплату (форма выбора).
|
||||
Используется когда FREEKASSA_USE_API = False.
|
||||
"""
|
||||
# Приводим amount к int, если это целое число
|
||||
final_amount = int(amount) if float(amount).is_integer() else amount
|
||||
signature = self.generate_form_signature(final_amount, currency, order_id)
|
||||
|
||||
params = {
|
||||
"m": self.shop_id,
|
||||
"oa": final_amount,
|
||||
"currency": currency,
|
||||
"o": order_id,
|
||||
"s": signature,
|
||||
"lang": lang,
|
||||
}
|
||||
|
||||
if email:
|
||||
params["em"] = email
|
||||
if phone:
|
||||
params["phone"] = phone
|
||||
|
||||
# Используем payment_system_id из настроек, если не передан явно
|
||||
ps_id = payment_system_id or settings.FREEKASSA_PAYMENT_SYSTEM_ID
|
||||
if ps_id:
|
||||
params["i"] = ps_id
|
||||
|
||||
query = "&".join(f"{k}={v}" for k, v in params.items())
|
||||
return f"https://pay.freekassa.ru/?{query}"
|
||||
|
||||
async def create_order(
|
||||
self,
|
||||
order_id: str,
|
||||
amount: float,
|
||||
currency: str = "RUB",
|
||||
email: Optional[str] = None,
|
||||
ip: Optional[str] = None,
|
||||
payment_system_id: Optional[int] = None,
|
||||
success_url: Optional[str] = None,
|
||||
failure_url: Optional[str] = None,
|
||||
notification_url: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Создает заказ через API Freekassa.
|
||||
POST /orders/create
|
||||
|
||||
Используется для NSPK СБП (payment_system_id=44) и других методов.
|
||||
Возвращает словарь с 'location' (ссылка на оплату).
|
||||
"""
|
||||
# Приводим amount к int, если это целое число
|
||||
final_amount = int(amount) if float(amount).is_integer() else amount
|
||||
|
||||
# Используем payment_system_id из настроек, если не передан явно
|
||||
ps_id = payment_system_id or settings.FREEKASSA_PAYMENT_SYSTEM_ID or 1
|
||||
|
||||
params = {
|
||||
"shopId": self.shop_id,
|
||||
"nonce": int(time.time_ns()), # Наносекунды для уникальности
|
||||
"paymentId": str(order_id),
|
||||
"i": ps_id,
|
||||
"email": email or "user@example.com",
|
||||
"ip": ip or "127.0.0.1",
|
||||
"amount": final_amount,
|
||||
"currency": currency,
|
||||
}
|
||||
|
||||
# Генерируем подпись HMAC-SHA256
|
||||
params["signature"] = self._generate_api_signature(params)
|
||||
|
||||
logger.info(f"Freekassa API create_order params: {params}")
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f"{API_BASE_URL}/orders/create",
|
||||
json=params,
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
) as response:
|
||||
text = await response.text()
|
||||
logger.info(f"Freekassa API response: {text}")
|
||||
|
||||
data = await response.json()
|
||||
|
||||
if response.status != 200 or data.get("type") == "error":
|
||||
logger.error(f"Freekassa create_order error: {data}")
|
||||
raise Exception(
|
||||
f"Freekassa API error: {data.get('message', 'Unknown error')}"
|
||||
)
|
||||
|
||||
return data
|
||||
except aiohttp.ClientError as e:
|
||||
logger.exception(f"Freekassa API connection error: {e}")
|
||||
raise
|
||||
|
||||
async def create_order_and_get_url(
|
||||
self,
|
||||
order_id: str,
|
||||
amount: float,
|
||||
currency: str = "RUB",
|
||||
email: Optional[str] = None,
|
||||
ip: Optional[str] = None,
|
||||
payment_system_id: Optional[int] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Создает заказ через API и возвращает URL для оплаты.
|
||||
Удобный метод для получения только ссылки.
|
||||
"""
|
||||
result = await self.create_order(
|
||||
order_id=order_id,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
email=email,
|
||||
ip=ip,
|
||||
payment_system_id=payment_system_id,
|
||||
)
|
||||
location = result.get("location")
|
||||
if not location:
|
||||
raise Exception("Freekassa API did not return payment URL (location)")
|
||||
return location
|
||||
|
||||
async def get_order_status(self, order_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Получает статус заказа.
|
||||
POST /orders
|
||||
"""
|
||||
params = {
|
||||
"shopId": self.shop_id,
|
||||
"nonce": int(time.time_ns()),
|
||||
"paymentId": str(order_id),
|
||||
}
|
||||
params["signature"] = self._generate_api_signature(params)
|
||||
|
||||
logger.info(f"Freekassa get_order_status params: {params}")
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f"{API_BASE_URL}/orders",
|
||||
json=params,
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
) as response:
|
||||
text = await response.text()
|
||||
logger.info(f"Freekassa get_order_status response: {text}")
|
||||
return await response.json()
|
||||
except aiohttp.ClientError as e:
|
||||
logger.exception(f"Freekassa API connection error: {e}")
|
||||
raise
|
||||
|
||||
async def get_balance(self) -> Dict[str, Any]:
|
||||
"""Получает баланс магазина."""
|
||||
params = {
|
||||
"shopId": self.shop_id,
|
||||
"nonce": int(time.time_ns()),
|
||||
}
|
||||
params["signature"] = self._generate_api_signature(params)
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f"{API_BASE_URL}/balance",
|
||||
json=params,
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
) as response:
|
||||
return await response.json()
|
||||
except aiohttp.ClientError as e:
|
||||
logger.exception(f"Freekassa API connection error: {e}")
|
||||
raise
|
||||
|
||||
async def get_payment_systems(self) -> Dict[str, Any]:
|
||||
"""Получает список доступных платежных систем."""
|
||||
params = {
|
||||
"shopId": self.shop_id,
|
||||
"nonce": int(time.time_ns()),
|
||||
}
|
||||
params["signature"] = self._generate_api_signature(params)
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f"{API_BASE_URL}/currencies",
|
||||
json=params,
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
) as response:
|
||||
return await response.json()
|
||||
except aiohttp.ClientError as e:
|
||||
logger.exception(f"Freekassa API connection error: {e}")
|
||||
raise
|
||||
|
||||
|
||||
# Singleton instance
|
||||
freekassa_service = FreekassaService()
|
||||
@@ -719,6 +719,9 @@ class MenuLayoutService:
|
||||
if conditions.get("traffic_topup_enabled") is True:
|
||||
if not settings.is_traffic_topup_enabled():
|
||||
return False
|
||||
# В режиме тарифов докупка трафика недоступна
|
||||
if settings.is_tariffs_mode():
|
||||
return False
|
||||
|
||||
# is_admin
|
||||
if conditions.get("is_admin") is True:
|
||||
|
||||
@@ -299,6 +299,9 @@ class NalogoQueueService:
|
||||
queue_length = 0
|
||||
total_amount = 0.0
|
||||
queued_receipts = []
|
||||
pending_verification_count = 0
|
||||
pending_verification_amount = 0.0
|
||||
pending_verification_receipts = []
|
||||
|
||||
if self._nalogo_service:
|
||||
queue_length = await self._nalogo_service.get_queue_length()
|
||||
@@ -306,6 +309,12 @@ class NalogoQueueService:
|
||||
queued_receipts = await self._nalogo_service.get_queued_receipts()
|
||||
total_amount = sum(r.get("amount", 0) for r in queued_receipts)
|
||||
|
||||
# Чеки ожидающие ручной проверки
|
||||
pending_verification_count = await self._nalogo_service.get_pending_verification_count()
|
||||
if pending_verification_count > 0:
|
||||
pending_verification_receipts = await self._nalogo_service.get_pending_verification_receipts()
|
||||
pending_verification_amount = sum(r.get("amount", 0) for r in pending_verification_receipts)
|
||||
|
||||
return {
|
||||
"running": self.is_running(),
|
||||
"check_interval_seconds": self._check_interval,
|
||||
@@ -313,7 +322,11 @@ class NalogoQueueService:
|
||||
"queue_length": queue_length,
|
||||
"total_amount": total_amount,
|
||||
"max_attempts": self._max_attempts,
|
||||
"queued_receipts": queued_receipts[:10], # Показываем только первые 10
|
||||
"queued_receipts": queued_receipts[:10],
|
||||
# Чеки требующие ручной проверки (таймаут после успешной авторизации)
|
||||
"pending_verification_count": pending_verification_count,
|
||||
"pending_verification_amount": pending_verification_amount,
|
||||
"pending_verification_receipts": pending_verification_receipts[:10],
|
||||
}
|
||||
|
||||
|
||||
|
||||
+266
-19
@@ -13,6 +13,7 @@ from app.utils.cache import cache
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NALOGO_QUEUE_KEY = "nalogo:receipt_queue"
|
||||
NALOGO_PENDING_VERIFICATION_KEY = "nalogo:pending_verification"
|
||||
|
||||
|
||||
class NaloGoService:
|
||||
@@ -37,10 +38,13 @@ class NaloGoService:
|
||||
"Функционал чеков будет ОТКЛЮЧЕН.")
|
||||
else:
|
||||
try:
|
||||
# Таймаут 30 секунд — nalog.ru иногда отвечает медленно
|
||||
timeout = getattr(settings, 'NALOGO_TIMEOUT', 30.0)
|
||||
self.client = Client(
|
||||
base_url="https://lknpd.nalog.ru/api",
|
||||
storage_path=storage_path,
|
||||
device_id=device_id or "bot-device-123"
|
||||
device_id=device_id or "bot-device-123",
|
||||
timeout=timeout,
|
||||
)
|
||||
self.inn = inn
|
||||
self.password = password
|
||||
@@ -99,10 +103,11 @@ class NaloGoService:
|
||||
)
|
||||
return False
|
||||
|
||||
# Проверяем не в очереди ли уже
|
||||
# Атомарная проверка и установка флага "в очереди" (защита от race condition)
|
||||
queued_key = f"nalogo:queued:{payment_id}"
|
||||
already_queued = await cache.get(queued_key)
|
||||
if already_queued:
|
||||
lock_acquired = await cache.setnx(queued_key, "queued", expire=7 * 24 * 3600)
|
||||
if not lock_acquired:
|
||||
# Ключ уже существует — чек уже в очереди
|
||||
logger.info(
|
||||
f"Чек для payment_id={payment_id} уже в очереди, пропускаем дубликат"
|
||||
)
|
||||
@@ -121,18 +126,152 @@ class NaloGoService:
|
||||
}
|
||||
success = await cache.lpush(NALOGO_QUEUE_KEY, receipt_data)
|
||||
if success:
|
||||
# Помечаем что чек в очереди (TTL 7 дней)
|
||||
if payment_id:
|
||||
queued_key = f"nalogo:queued:{payment_id}"
|
||||
await cache.set(queued_key, "queued", expire=7 * 24 * 3600)
|
||||
|
||||
queue_len = await cache.llen(NALOGO_QUEUE_KEY)
|
||||
logger.info(
|
||||
f"Чек добавлен в очередь (payment_id={payment_id}, "
|
||||
f"сумма={amount}₽, в очереди: {queue_len})"
|
||||
)
|
||||
else:
|
||||
# Если не удалось добавить в очередь — удаляем флаг
|
||||
if payment_id:
|
||||
queued_key = f"nalogo:queued:{payment_id}"
|
||||
await cache.delete(queued_key)
|
||||
return success
|
||||
|
||||
async def _save_pending_verification(
|
||||
self,
|
||||
name: str,
|
||||
amount: float,
|
||||
quantity: int,
|
||||
client_info: Optional[Dict[str, Any]],
|
||||
payment_id: Optional[str],
|
||||
telegram_user_id: Optional[int],
|
||||
amount_kopeks: Optional[int],
|
||||
error_message: str,
|
||||
) -> bool:
|
||||
"""Сохранить чек в очередь ожидающих проверки.
|
||||
|
||||
Используется когда таймаут произошёл ПОСЛЕ успешной аутентификации —
|
||||
чек мог быть создан на сервере, но ответ не пришёл.
|
||||
"""
|
||||
receipt_data = {
|
||||
"name": name,
|
||||
"amount": amount,
|
||||
"quantity": quantity,
|
||||
"client_info": client_info,
|
||||
"payment_id": payment_id,
|
||||
"telegram_user_id": telegram_user_id,
|
||||
"amount_kopeks": amount_kopeks,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"error": error_message,
|
||||
"status": "pending_verification",
|
||||
}
|
||||
success = await cache.lpush(NALOGO_PENDING_VERIFICATION_KEY, receipt_data)
|
||||
if success:
|
||||
count = await cache.llen(NALOGO_PENDING_VERIFICATION_KEY)
|
||||
logger.warning(
|
||||
f"Чек сохранён для ручной проверки (payment_id={payment_id}, "
|
||||
f"сумма={amount}₽, всего ожидают проверки: {count})"
|
||||
)
|
||||
return success
|
||||
|
||||
async def get_pending_verification_count(self) -> int:
|
||||
"""Получить количество чеков ожидающих проверки."""
|
||||
return await cache.llen(NALOGO_PENDING_VERIFICATION_KEY)
|
||||
|
||||
async def get_pending_verification_receipts(self) -> list:
|
||||
"""Получить список чеков ожидающих проверки."""
|
||||
return await cache.lrange(NALOGO_PENDING_VERIFICATION_KEY)
|
||||
|
||||
async def mark_pending_as_verified(
|
||||
self,
|
||||
payment_id: str,
|
||||
receipt_uuid: Optional[str] = None,
|
||||
was_created: bool = True,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Пометить чек как проверенный и удалить из очереди.
|
||||
|
||||
Args:
|
||||
payment_id: ID платежа
|
||||
receipt_uuid: UUID чека если был создан в налоговой
|
||||
was_created: True если чек был создан, False если не был
|
||||
|
||||
Returns:
|
||||
Данные удалённого чека или None если не найден
|
||||
"""
|
||||
receipts = await self.get_pending_verification_receipts()
|
||||
updated_receipts = []
|
||||
removed_receipt = None
|
||||
|
||||
for receipt in receipts:
|
||||
if receipt.get("payment_id") == payment_id:
|
||||
removed_receipt = receipt
|
||||
if was_created and receipt_uuid:
|
||||
# Сохраняем что чек создан
|
||||
created_key = f"nalogo:created:{payment_id}"
|
||||
await cache.set(created_key, receipt_uuid, expire=30 * 24 * 3600)
|
||||
logger.info(
|
||||
f"Чек {payment_id} помечен как созданный: {receipt_uuid}"
|
||||
)
|
||||
else:
|
||||
updated_receipts.append(receipt)
|
||||
|
||||
if removed_receipt:
|
||||
# Очищаем и перезаписываем список
|
||||
await cache.delete(NALOGO_PENDING_VERIFICATION_KEY)
|
||||
for r in reversed(updated_receipts): # reversed чтобы сохранить порядок
|
||||
await cache.lpush(NALOGO_PENDING_VERIFICATION_KEY, r)
|
||||
logger.info(f"Чек {payment_id} удалён из очереди проверки")
|
||||
|
||||
return removed_receipt
|
||||
|
||||
async def retry_pending_receipt(self, payment_id: str) -> Optional[str]:
|
||||
"""Повторно отправить чек из очереди проверки.
|
||||
|
||||
Используется когда проверили что чек НЕ был создан в налоговой.
|
||||
|
||||
Returns:
|
||||
UUID созданного чека или None
|
||||
"""
|
||||
receipts = await self.get_pending_verification_receipts()
|
||||
target_receipt = None
|
||||
|
||||
for receipt in receipts:
|
||||
if receipt.get("payment_id") == payment_id:
|
||||
target_receipt = receipt
|
||||
break
|
||||
|
||||
if not target_receipt:
|
||||
logger.warning(f"Чек {payment_id} не найден в очереди проверки")
|
||||
return None
|
||||
|
||||
# Пытаемся создать чек
|
||||
receipt_uuid = await self.create_receipt(
|
||||
name=target_receipt.get("name", ""),
|
||||
amount=target_receipt.get("amount", 0),
|
||||
quantity=target_receipt.get("quantity", 1),
|
||||
client_info=target_receipt.get("client_info"),
|
||||
payment_id=payment_id,
|
||||
queue_on_failure=False, # Не добавлять обратно в очередь
|
||||
telegram_user_id=target_receipt.get("telegram_user_id"),
|
||||
amount_kopeks=target_receipt.get("amount_kopeks"),
|
||||
)
|
||||
|
||||
if receipt_uuid:
|
||||
# Удаляем из очереди проверки
|
||||
await self.mark_pending_as_verified(payment_id, receipt_uuid, was_created=True)
|
||||
logger.info(f"Чек {payment_id} успешно создан после ручной проверки: {receipt_uuid}")
|
||||
|
||||
return receipt_uuid
|
||||
|
||||
async def clear_pending_verification(self) -> int:
|
||||
"""Очистить всю очередь проверки (после полной ручной сверки)."""
|
||||
count = await self.get_pending_verification_count()
|
||||
if count > 0:
|
||||
await cache.delete(NALOGO_PENDING_VERIFICATION_KEY)
|
||||
logger.info(f"Очередь проверки очищена: удалено {count} чеков")
|
||||
return count
|
||||
|
||||
async def authenticate(self) -> bool:
|
||||
"""Аутентификация в сервисе NaloGO."""
|
||||
if not self.configured:
|
||||
@@ -196,19 +335,41 @@ class NaloGoService:
|
||||
)
|
||||
return already_created # Возвращаем ранее созданный uuid
|
||||
|
||||
# ЭТАП 1: Аутентификация
|
||||
# Если не прошла — чек точно не создавался, безопасно добавить в очередь
|
||||
auth_was_successful = False
|
||||
try:
|
||||
# Аутентифицируемся, если нужно
|
||||
if not hasattr(self.client, '_access_token') or not self.client._access_token:
|
||||
auth_success = await self.authenticate()
|
||||
if not auth_success:
|
||||
# Если сервис недоступен — добавляем в очередь
|
||||
# Аутентификация не прошла — чек не создавался, безопасно в очередь
|
||||
if queue_on_failure:
|
||||
await self._queue_receipt(
|
||||
name, amount, quantity, client_info, payment_id,
|
||||
telegram_user_id, amount_kopeks
|
||||
)
|
||||
return None
|
||||
auth_was_successful = True
|
||||
except Exception as auth_error:
|
||||
# Ошибка аутентификации — чек не создавался, безопасно в очередь
|
||||
if self._is_service_unavailable(auth_error):
|
||||
logger.warning(
|
||||
f"NaloGO недоступен при аутентификации, чек в очередь "
|
||||
f"(payment_id={payment_id}, сумма={amount}₽)"
|
||||
)
|
||||
if queue_on_failure:
|
||||
await self._queue_receipt(
|
||||
name, amount, quantity, client_info, payment_id,
|
||||
telegram_user_id, amount_kopeks
|
||||
)
|
||||
else:
|
||||
logger.error("Ошибка аутентификации NaloGO: %s", auth_error, exc_info=True)
|
||||
return None
|
||||
|
||||
# ЭТАП 2: Создание чека
|
||||
# Если аутентификация прошла и получили таймаут — чек МОГ быть создан!
|
||||
# НЕ добавляем в очередь, требуется ручная проверка
|
||||
try:
|
||||
income_api = self.client.income()
|
||||
|
||||
# Создаем клиента, если передана информация
|
||||
@@ -245,16 +406,26 @@ class NaloGoService:
|
||||
return None
|
||||
|
||||
except Exception as error:
|
||||
# ВАЖНО: Аутентификация была успешной, запрос на создание чека УШЁЛ
|
||||
# При таймауте чек МОГ быть создан на сервере — НЕ добавляем в очередь!
|
||||
if self._is_service_unavailable(error):
|
||||
logger.warning(
|
||||
"NaloGO временно недоступен, чек будет отправлен позже "
|
||||
f"(payment_id={payment_id}, сумма={amount}₽)"
|
||||
error_msg = str(error)[:200]
|
||||
logger.error(
|
||||
f"⚠️ ТАЙМАУТ после успешной аутентификации! Чек МОГ быть создан! "
|
||||
f"(payment_id={payment_id}, сумма={amount}₽). "
|
||||
f"Сохраняем в очередь проверки. Проверьте lknpd.nalog.ru"
|
||||
)
|
||||
# Сохраняем в очередь для ручной проверки
|
||||
await self._save_pending_verification(
|
||||
name=name,
|
||||
amount=amount,
|
||||
quantity=quantity,
|
||||
client_info=client_info,
|
||||
payment_id=payment_id,
|
||||
telegram_user_id=telegram_user_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
error_message=error_msg,
|
||||
)
|
||||
if queue_on_failure:
|
||||
await self._queue_receipt(
|
||||
name, amount, quantity, client_info, payment_id,
|
||||
telegram_user_id, amount_kopeks
|
||||
)
|
||||
else:
|
||||
logger.error("Ошибка создания чека в NaloGO: %s", error, exc_info=True)
|
||||
return None
|
||||
@@ -276,6 +447,82 @@ class NaloGoService:
|
||||
receipt_data["attempts"] = receipt_data.get("attempts", 0) + 1
|
||||
return await cache.lpush(NALOGO_QUEUE_KEY, receipt_data)
|
||||
|
||||
async def find_duplicate_receipt(
|
||||
self,
|
||||
amount: float,
|
||||
created_at: datetime,
|
||||
time_window_minutes: int = 10,
|
||||
) -> Optional[str]:
|
||||
"""Проверяет, не был ли уже создан чек с такой суммой в заданном временном окне.
|
||||
|
||||
Используется для защиты от дублей при таймаутах — когда сервер создал чек,
|
||||
но ответ не вернулся.
|
||||
|
||||
Args:
|
||||
amount: Сумма чека в рублях
|
||||
created_at: Время создания записи в очереди
|
||||
time_window_minutes: Окно поиска в минутах (±)
|
||||
|
||||
Returns:
|
||||
UUID чека если дубликат найден, None если не найден
|
||||
"""
|
||||
if not self.configured:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Запрашиваем чеки за день когда был создан запрос
|
||||
from_date = created_at.date()
|
||||
to_date = from_date + timedelta(days=1)
|
||||
|
||||
incomes = await self.get_incomes(
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
limit=50,
|
||||
)
|
||||
|
||||
if not incomes:
|
||||
return None
|
||||
|
||||
# Ищем чек с такой же суммой в пределах временного окна
|
||||
for income in incomes:
|
||||
income_amount = float(income.get("totalAmount", income.get("amount", 0)))
|
||||
|
||||
# Проверяем сумму (с погрешностью 0.01)
|
||||
if abs(income_amount - amount) > 0.01:
|
||||
continue
|
||||
|
||||
# Проверяем время
|
||||
operation_time_str = income.get("operationTime")
|
||||
if operation_time_str:
|
||||
try:
|
||||
from dateutil.parser import isoparse
|
||||
operation_time = isoparse(operation_time_str)
|
||||
|
||||
# Убираем timezone для сравнения
|
||||
if operation_time.tzinfo:
|
||||
operation_time = operation_time.replace(tzinfo=None)
|
||||
created_at_naive = created_at.replace(tzinfo=None) if created_at.tzinfo else created_at
|
||||
|
||||
time_diff = abs((operation_time - created_at_naive).total_seconds())
|
||||
if time_diff <= time_window_minutes * 60:
|
||||
receipt_uuid = income.get("approvedReceiptUuid", income.get("receiptUuid"))
|
||||
if receipt_uuid:
|
||||
logger.info(
|
||||
f"Найден дубликат чека: {receipt_uuid} "
|
||||
f"(сумма={income_amount}₽, время={operation_time}, "
|
||||
f"разница={time_diff:.0f}с)"
|
||||
)
|
||||
return receipt_uuid
|
||||
except Exception as parse_error:
|
||||
logger.debug(f"Ошибка парсинга времени чека: {parse_error}")
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
except Exception as error:
|
||||
logger.warning(f"Ошибка проверки дубликата чека: {error}")
|
||||
return None
|
||||
|
||||
async def get_incomes(
|
||||
self,
|
||||
from_date: Optional[date] = None,
|
||||
|
||||
@@ -14,6 +14,8 @@ from .mulenpay import MulenPayPaymentMixin
|
||||
from .pal24 import Pal24PaymentMixin
|
||||
from .platega import PlategaPaymentMixin
|
||||
from .wata import WataPaymentMixin
|
||||
from .cloudpayments import CloudPaymentsPaymentMixin
|
||||
from .freekassa import FreekassaPaymentMixin
|
||||
|
||||
__all__ = [
|
||||
"PaymentCommonMixin",
|
||||
@@ -26,4 +28,6 @@ __all__ = [
|
||||
"Pal24PaymentMixin",
|
||||
"PlategaPaymentMixin",
|
||||
"WataPaymentMixin",
|
||||
"CloudPaymentsPaymentMixin",
|
||||
"FreekassaPaymentMixin",
|
||||
]
|
||||
|
||||
@@ -71,15 +71,8 @@ class PaymentCommonMixin:
|
||||
),
|
||||
)
|
||||
|
||||
# Кнопка активации подписки (всегда отображается)
|
||||
activate_subscription_button = build_miniapp_or_callback_button(
|
||||
text="🚀 Активировать подписку",
|
||||
callback_data="menu_buy" # Используем ту же callback_data что и "Купить подписку"
|
||||
)
|
||||
|
||||
keyboard_rows: list[list[InlineKeyboardButton]] = [
|
||||
[first_button],
|
||||
[activate_subscription_button]
|
||||
]
|
||||
|
||||
# Если для пользователя есть незавершённый checkout, предлагаем вернуться к нему.
|
||||
|
||||
@@ -0,0 +1,533 @@
|
||||
"""Mixin для интеграции с Freekassa."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from importlib import import_module
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.freekassa_service import freekassa_service
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
|
||||
|
||||
class FreekassaPaymentMixin:
|
||||
"""Mixin для работы с платежами Freekassa."""
|
||||
|
||||
async def create_freekassa_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str = "Пополнение баланса",
|
||||
email: Optional[str] = None,
|
||||
language: str = "ru",
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Создает платеж Freekassa.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
user_id: ID пользователя
|
||||
amount_kopeks: Сумма в копейках
|
||||
description: Описание платежа
|
||||
email: Email пользователя
|
||||
language: Язык интерфейса
|
||||
|
||||
Returns:
|
||||
Словарь с данными платежа или None при ошибке
|
||||
"""
|
||||
if not settings.is_freekassa_enabled():
|
||||
logger.error("Freekassa не настроен")
|
||||
return None
|
||||
|
||||
# Валидация лимитов
|
||||
if amount_kopeks < settings.FREEKASSA_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
"Freekassa: сумма %s меньше минимальной %s",
|
||||
amount_kopeks,
|
||||
settings.FREEKASSA_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.FREEKASSA_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
"Freekassa: сумма %s больше максимальной %s",
|
||||
amount_kopeks,
|
||||
settings.FREEKASSA_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
# Генерируем уникальный order_id
|
||||
order_id = f"fk_{user_id}_{uuid.uuid4().hex[:12]}"
|
||||
amount_rubles = amount_kopeks / 100
|
||||
currency = settings.FREEKASSA_CURRENCY
|
||||
|
||||
# Срок действия платежа
|
||||
expires_at = datetime.utcnow() + timedelta(
|
||||
seconds=settings.FREEKASSA_PAYMENT_TIMEOUT_SECONDS
|
||||
)
|
||||
|
||||
# Метаданные
|
||||
metadata = {
|
||||
"user_id": user_id,
|
||||
"amount_kopeks": amount_kopeks,
|
||||
"description": description,
|
||||
"language": language,
|
||||
"type": "balance_topup",
|
||||
}
|
||||
|
||||
try:
|
||||
# Выбираем способ создания платежа: API или форма
|
||||
if settings.FREEKASSA_USE_API:
|
||||
# Используем API для создания заказа (нужно для NSPK СБП)
|
||||
payment_url = await freekassa_service.create_order_and_get_url(
|
||||
order_id=order_id,
|
||||
amount=amount_rubles,
|
||||
currency=currency,
|
||||
email=email,
|
||||
payment_system_id=settings.FREEKASSA_PAYMENT_SYSTEM_ID,
|
||||
)
|
||||
logger.info(
|
||||
"Freekassa API: создан заказ order_id=%s, url=%s",
|
||||
order_id,
|
||||
payment_url,
|
||||
)
|
||||
else:
|
||||
# Генерируем URL для формы оплаты (стандартный способ)
|
||||
payment_url = freekassa_service.build_payment_url(
|
||||
order_id=order_id,
|
||||
amount=amount_rubles,
|
||||
currency=currency,
|
||||
email=email,
|
||||
lang=language,
|
||||
)
|
||||
|
||||
# Импортируем CRUD модуль
|
||||
freekassa_crud = import_module("app.database.crud.freekassa")
|
||||
|
||||
# Сохраняем в БД
|
||||
local_payment = await freekassa_crud.create_freekassa_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
expires_at=expires_at,
|
||||
metadata_json=json.dumps(metadata, ensure_ascii=False),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Freekassa: создан платеж order_id=%s, user_id=%s, amount=%s %s, use_api=%s",
|
||||
order_id,
|
||||
user_id,
|
||||
amount_rubles,
|
||||
currency,
|
||||
settings.FREEKASSA_USE_API,
|
||||
)
|
||||
|
||||
return {
|
||||
"order_id": order_id,
|
||||
"amount_kopeks": amount_kopeks,
|
||||
"amount_rubles": amount_rubles,
|
||||
"currency": currency,
|
||||
"payment_url": payment_url,
|
||||
"expires_at": expires_at.isoformat(),
|
||||
"local_payment_id": local_payment.id,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Freekassa: ошибка создания платежа: %s", e)
|
||||
return None
|
||||
|
||||
async def process_freekassa_webhook(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
merchant_id: int,
|
||||
amount: float,
|
||||
order_id: str,
|
||||
sign: str,
|
||||
intid: str,
|
||||
cur_id: Optional[int] = None,
|
||||
client_ip: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Обрабатывает webhook от Freekassa.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
merchant_id: ID магазина (MERCHANT_ID)
|
||||
amount: Сумма платежа (AMOUNT)
|
||||
order_id: Номер заказа (MERCHANT_ORDER_ID)
|
||||
sign: Подпись (SIGN)
|
||||
intid: ID транзакции Freekassa
|
||||
cur_id: ID валюты/платежной системы (CUR_ID)
|
||||
client_ip: IP клиента
|
||||
|
||||
Returns:
|
||||
True если платеж успешно обработан
|
||||
"""
|
||||
try:
|
||||
# Проверка IP
|
||||
if not freekassa_service.verify_webhook_ip(client_ip):
|
||||
logger.warning("Freekassa webhook: недоверенный IP %s", client_ip)
|
||||
return False
|
||||
|
||||
# Проверка подписи
|
||||
if not freekassa_service.verify_webhook_signature(
|
||||
merchant_id, amount, order_id, sign
|
||||
):
|
||||
logger.warning(
|
||||
"Freekassa webhook: неверная подпись для order_id=%s", order_id
|
||||
)
|
||||
return False
|
||||
|
||||
# Импортируем CRUD модуль
|
||||
freekassa_crud = import_module("app.database.crud.freekassa")
|
||||
|
||||
# Получаем платеж из БД
|
||||
payment = await freekassa_crud.get_freekassa_payment_by_order_id(
|
||||
db, order_id
|
||||
)
|
||||
if not payment:
|
||||
logger.warning(
|
||||
"Freekassa webhook: платеж не найден order_id=%s", order_id
|
||||
)
|
||||
return False
|
||||
|
||||
# Проверка дублирования
|
||||
if payment.is_paid:
|
||||
logger.info(
|
||||
"Freekassa webhook: платеж уже обработан order_id=%s", order_id
|
||||
)
|
||||
return True
|
||||
|
||||
# Проверка суммы
|
||||
expected_amount = payment.amount_kopeks / 100
|
||||
if abs(amount - expected_amount) > 0.01:
|
||||
logger.warning(
|
||||
"Freekassa webhook: несоответствие суммы ожидалось=%s, получено=%s",
|
||||
expected_amount,
|
||||
amount,
|
||||
)
|
||||
return False
|
||||
|
||||
# Обновляем статус платежа
|
||||
callback_payload = {
|
||||
"merchant_id": merchant_id,
|
||||
"amount": amount,
|
||||
"order_id": order_id,
|
||||
"intid": intid,
|
||||
"cur_id": cur_id,
|
||||
}
|
||||
|
||||
payment = await freekassa_crud.update_freekassa_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status="success",
|
||||
is_paid=True,
|
||||
freekassa_order_id=intid,
|
||||
payment_system_id=cur_id,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
|
||||
# Финализируем платеж (начисляем баланс, создаем транзакцию)
|
||||
return await self._finalize_freekassa_payment(
|
||||
db, payment, intid=intid, trigger="webhook"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Freekassa webhook: ошибка обработки: %s", e)
|
||||
return False
|
||||
|
||||
async def _finalize_freekassa_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: Any,
|
||||
*,
|
||||
intid: Optional[str],
|
||||
trigger: str,
|
||||
) -> bool:
|
||||
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления."""
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
"Freekassa платеж %s уже привязан к транзакции (trigger=%s)",
|
||||
payment.order_id,
|
||||
trigger,
|
||||
)
|
||||
return True
|
||||
|
||||
# Получаем пользователя
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error(
|
||||
"Пользователь %s не найден для Freekassa платежа %s (trigger=%s)",
|
||||
payment.user_id,
|
||||
payment.order_id,
|
||||
trigger,
|
||||
)
|
||||
return False
|
||||
|
||||
# Создаем транзакцию
|
||||
transaction = await payment_module.create_transaction(
|
||||
db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=f"Пополнение через Freekassa (#{intid or payment.order_id})",
|
||||
payment_method=PaymentMethod.FREEKASSA,
|
||||
external_id=str(intid) if intid else payment.order_id,
|
||||
is_completed=True,
|
||||
)
|
||||
|
||||
# Связываем платеж с транзакцией
|
||||
freekassa_crud = import_module("app.database.crud.freekassa")
|
||||
await freekassa_crud.update_freekassa_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=payment.status,
|
||||
transaction_id=transaction.id,
|
||||
)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
# Начисляем баланс
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
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()
|
||||
|
||||
# Обработка реферального пополнения
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db, user.id, payment.amount_kopeks, getattr(self, "bot", None)
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка обработки реферального пополнения Freekassa: %s", error
|
||||
)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
await db.refresh(payment)
|
||||
|
||||
# Отправка уведомления админам
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from app.services.admin_notification_service import (
|
||||
AdminNotificationService,
|
||||
)
|
||||
|
||||
notification_service = AdminNotificationService(self.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(
|
||||
"Ошибка отправки админ уведомления Freekassa: %s", error
|
||||
)
|
||||
|
||||
# Отправка уведомления пользователю
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
display_name = settings.get_freekassa_display_name()
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
|
||||
f"💳 Способ: {display_name}\n"
|
||||
f"🆔 Транзакция: {transaction.id}\n\n"
|
||||
"Баланс пополнен автоматически!"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления пользователю Freekassa: %s", error
|
||||
)
|
||||
|
||||
# Автопокупка подписки
|
||||
try:
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from aiogram import types
|
||||
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
auto_purchase_success = False
|
||||
|
||||
if has_saved_cart:
|
||||
try:
|
||||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as auto_error:
|
||||
logger.error(
|
||||
"Ошибка автоматической покупки подписки для пользователя %s: %s",
|
||||
user.id,
|
||||
auto_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
await auto_activate_subscription_after_topup(db, user)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
"Ошибка умной автоактивации для пользователя %s: %s",
|
||||
user.id,
|
||||
auto_activate_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.t(
|
||||
"BALANCE_TOPUP_CART_REMINDER",
|
||||
"У вас есть незавершенное оформление подписки. Вернуться?",
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"BALANCE_TOPUP_CART_BUTTON",
|
||||
"🛒 Продолжить оформление",
|
||||
),
|
||||
callback_data="return_to_saved_cart",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🏠 Главное меню",
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=(
|
||||
"✅ Баланс пополнен на "
|
||||
f"{settings.format_price(payment.amount_kopeks)}!\n\n"
|
||||
f"{cart_message}"
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка при работе с сохраненной корзиной для пользователя %s: %s",
|
||||
user.id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ Обработан Freekassa платеж %s для пользователя %s (trigger=%s)",
|
||||
payment.order_id,
|
||||
payment.user_id,
|
||||
trigger,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def check_freekassa_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
order_id: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Проверяет статус платежа через API.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
order_id: Номер заказа
|
||||
|
||||
Returns:
|
||||
Данные о статусе платежа
|
||||
"""
|
||||
try:
|
||||
status_data = await freekassa_service.get_order_status(order_id)
|
||||
return status_data
|
||||
except Exception as e:
|
||||
logger.exception("Freekassa: ошибка проверки статуса: %s", e)
|
||||
return None
|
||||
|
||||
async def get_freekassa_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
local_payment_id: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Проверяет статус платежа Freekassa по локальному ID.
|
||||
|
||||
Freekassa не предоставляет API для проверки статуса платежа,
|
||||
поэтому возвращаем текущее состояние из БД.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
local_payment_id: Внутренний ID платежа
|
||||
|
||||
Returns:
|
||||
Dict с информацией о платеже или None если не найден
|
||||
"""
|
||||
freekassa_crud = import_module("app.database.crud.freekassa")
|
||||
|
||||
payment = await freekassa_crud.get_freekassa_payment_by_id(db, local_payment_id)
|
||||
if not payment:
|
||||
logger.warning("Freekassa payment not found: id=%s", local_payment_id)
|
||||
return None
|
||||
|
||||
# Freekassa не имеет API для проверки статуса,
|
||||
# информация приходит только через webhook
|
||||
return {
|
||||
"payment": payment,
|
||||
"status": payment.status or "pending",
|
||||
"is_paid": payment.is_paid,
|
||||
}
|
||||
@@ -667,13 +667,10 @@ class YooKassaPaymentMixin:
|
||||
)
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
|
||||
# Обновляем пользователя, чтобы избежать проблем с ленивой загрузкой
|
||||
from app.database.crud.user import get_user_by_id
|
||||
refreshed_user = await get_user_by_id(db, user.id)
|
||||
|
||||
# Перезагрузка user при lazy-loading ошибке
|
||||
# происходит внутри send_balance_topup_notification
|
||||
await notification_service.send_balance_topup_notification(
|
||||
refreshed_user or user,
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
|
||||
@@ -29,6 +29,7 @@ from app.services.payment import (
|
||||
WataPaymentMixin,
|
||||
)
|
||||
from app.services.payment.cloudpayments import CloudPaymentsPaymentMixin
|
||||
from app.services.payment.freekassa import FreekassaPaymentMixin
|
||||
from app.services.yookassa_service import YooKassaService
|
||||
from app.services.wata_service import WataService
|
||||
from app.services.cloudpayments_service import CloudPaymentsService
|
||||
@@ -297,6 +298,7 @@ class PaymentService(
|
||||
PlategaPaymentMixin,
|
||||
WataPaymentMixin,
|
||||
CloudPaymentsPaymentMixin,
|
||||
FreekassaPaymentMixin,
|
||||
):
|
||||
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import (
|
||||
CloudPaymentsPayment,
|
||||
CryptoBotPayment,
|
||||
FreekassaPayment,
|
||||
HeleketPayment,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
@@ -66,6 +67,7 @@ SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
|
||||
PaymentMethod.CRYPTOBOT,
|
||||
PaymentMethod.PLATEGA,
|
||||
PaymentMethod.CLOUDPAYMENTS,
|
||||
PaymentMethod.FREEKASSA,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -79,6 +81,7 @@ SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
|
||||
PaymentMethod.CRYPTOBOT,
|
||||
PaymentMethod.PLATEGA,
|
||||
PaymentMethod.CLOUDPAYMENTS,
|
||||
PaymentMethod.FREEKASSA,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -100,6 +103,8 @@ def method_display_name(method: PaymentMethod) -> str:
|
||||
return "Heleket"
|
||||
if method == PaymentMethod.CLOUDPAYMENTS:
|
||||
return "CloudPayments"
|
||||
if method == PaymentMethod.FREEKASSA:
|
||||
return "Freekassa"
|
||||
if method == PaymentMethod.TELEGRAM_STARS:
|
||||
return "Telegram Stars"
|
||||
return method.value
|
||||
@@ -122,6 +127,8 @@ def _method_is_enabled(method: PaymentMethod) -> bool:
|
||||
return settings.is_heleket_enabled()
|
||||
if method == PaymentMethod.CLOUDPAYMENTS:
|
||||
return settings.is_cloudpayments_enabled()
|
||||
if method == PaymentMethod.FREEKASSA:
|
||||
return settings.is_freekassa_enabled()
|
||||
return False
|
||||
|
||||
|
||||
@@ -362,6 +369,13 @@ def _is_cloudpayments_pending(payment: CloudPaymentsPayment) -> bool:
|
||||
return status in {"pending", "authorized"}
|
||||
|
||||
|
||||
def _is_freekassa_pending(payment: FreekassaPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or "").lower()
|
||||
return status in {"pending", "created", "processing"}
|
||||
|
||||
|
||||
def _parse_cryptobot_amount_kopeks(payment: CryptoBotPayment) -> int:
|
||||
payload = payment.payload or ""
|
||||
match = re.search(r"_(\d+)$", payload)
|
||||
@@ -621,6 +635,31 @@ async def _fetch_cloudpayments_payments(db: AsyncSession, cutoff: datetime) -> L
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_freekassa_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
|
||||
stmt = (
|
||||
select(FreekassaPayment)
|
||||
.options(selectinload(FreekassaPayment.user))
|
||||
.where(FreekassaPayment.created_at >= cutoff)
|
||||
.order_by(desc(FreekassaPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: List[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_freekassa_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.FREEKASSA,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(payment.is_paid),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_stars_transactions(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
|
||||
stmt = (
|
||||
select(Transaction)
|
||||
@@ -666,6 +705,7 @@ async def list_recent_pending_payments(
|
||||
await _fetch_heleket_payments(db, cutoff),
|
||||
await _fetch_cryptobot_payments(db, cutoff),
|
||||
await _fetch_cloudpayments_payments(db, cutoff),
|
||||
await _fetch_freekassa_payments(db, cutoff),
|
||||
await _fetch_stars_transactions(db, cutoff),
|
||||
)
|
||||
|
||||
@@ -806,6 +846,20 @@ async def get_payment_record(
|
||||
is_paid=bool(payment.is_paid),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.FREEKASSA:
|
||||
payment = await db.get(FreekassaPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=["user"])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(payment.is_paid),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.TELEGRAM_STARS:
|
||||
transaction = await db.get(Transaction, local_payment_id)
|
||||
if not transaction:
|
||||
@@ -860,6 +914,9 @@ async def run_manual_check(
|
||||
elif method == PaymentMethod.CLOUDPAYMENTS:
|
||||
result = await payment_service.get_cloudpayments_payment_status(db, local_payment_id)
|
||||
payment = result.get("payment") if result else None
|
||||
elif method == PaymentMethod.FREEKASSA:
|
||||
result = await payment_service.get_freekassa_payment_status(db, local_payment_id)
|
||||
payment = result.get("payment") if result else None
|
||||
else:
|
||||
logger.warning("Manual check requested for unsupported method %s", method)
|
||||
return None
|
||||
|
||||
@@ -51,7 +51,12 @@ class PromoCodeService:
|
||||
existing_use = await check_user_promocode_usage(db, user_id, promocode.id)
|
||||
if existing_use:
|
||||
return {"success": False, "error": "already_used_by_user"}
|
||||
|
||||
|
||||
# Проверка "только для первой покупки"
|
||||
if getattr(promocode, 'first_purchase_only', False):
|
||||
if getattr(user, 'has_had_paid_subscription', False):
|
||||
return {"success": False, "error": "not_first_purchase"}
|
||||
|
||||
balance_before_kopeks = user.balance_kopeks
|
||||
|
||||
result_description = await self._apply_promocode_effects(db, user, promocode)
|
||||
|
||||
@@ -13,7 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.database.crud.subscription import extend_subscription
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.crud.user import get_user_by_id, subtract_user_balance
|
||||
from app.database.models import Subscription, TransactionType, User
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
@@ -73,6 +73,17 @@ async def _prepare_auto_purchase(
|
||||
)
|
||||
return None
|
||||
|
||||
# Перезагружаем user с нужными связями (user_promo_groups),
|
||||
# т.к. после db.refresh() в payment-сервисах связи сбрасываются
|
||||
fresh_user = await get_user_by_id(db, user.id)
|
||||
if not fresh_user:
|
||||
logger.warning(
|
||||
"🔁 Автопокупка: не удалось перезагрузить пользователя %s",
|
||||
user.telegram_id,
|
||||
)
|
||||
return None
|
||||
user = fresh_user
|
||||
|
||||
miniapp_service = MiniAppSubscriptionPurchaseService()
|
||||
context = await miniapp_service.build_options(db, user)
|
||||
|
||||
@@ -123,6 +134,57 @@ def _safe_int(value: Optional[object], default: int = 0) -> int:
|
||||
return default
|
||||
|
||||
|
||||
def _apply_promo_discount_for_tariff(price: int, discount_percent: int) -> int:
|
||||
"""Применяет скидку промогруппы к цене тарифа."""
|
||||
if discount_percent <= 0:
|
||||
return price
|
||||
discount = int(price * discount_percent / 100)
|
||||
return max(0, price - discount)
|
||||
|
||||
|
||||
async def _get_tariff_price_for_period(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
tariff_id: int,
|
||||
period_days: int,
|
||||
) -> Optional[int]:
|
||||
"""Получает актуальную цену тарифа для заданного периода с учётом скидки пользователя."""
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
if not tariff or not tariff.is_active:
|
||||
logger.warning(
|
||||
"🔁 Автопокупка: тариф %s недоступен для пользователя %s",
|
||||
tariff_id,
|
||||
user.telegram_id,
|
||||
)
|
||||
return None
|
||||
|
||||
prices = tariff.period_prices or {}
|
||||
base_price = prices.get(str(period_days))
|
||||
if base_price is None:
|
||||
logger.warning(
|
||||
"🔁 Автопокупка: период %s дней недоступен для тарифа %s",
|
||||
period_days,
|
||||
tariff_id,
|
||||
)
|
||||
return None
|
||||
|
||||
# Получаем скидку пользователя
|
||||
discount_percent = 0
|
||||
promo_group = getattr(user, 'promo_group', None)
|
||||
if promo_group:
|
||||
discount_percent = getattr(promo_group, 'server_discount_percent', 0)
|
||||
|
||||
personal_discount = get_user_active_promo_discount_percent(user)
|
||||
if personal_discount > discount_percent:
|
||||
discount_percent = personal_discount
|
||||
|
||||
final_price = _apply_promo_discount_for_tariff(base_price, discount_percent)
|
||||
return final_price
|
||||
|
||||
|
||||
async def _prepare_auto_extend_context(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
@@ -151,11 +213,6 @@ async def _prepare_auto_extend_context(
|
||||
return None
|
||||
|
||||
period_days = _safe_int(cart_data.get("period_days"))
|
||||
price_kopeks = _safe_int(
|
||||
cart_data.get("total_price")
|
||||
or cart_data.get("price")
|
||||
or cart_data.get("final_price"),
|
||||
)
|
||||
|
||||
if period_days <= 0:
|
||||
logger.warning(
|
||||
@@ -165,6 +222,30 @@ async def _prepare_auto_extend_context(
|
||||
)
|
||||
return None
|
||||
|
||||
# Если в корзине есть tariff_id - пересчитываем цену по актуальному тарифу
|
||||
tariff_id = cart_data.get("tariff_id")
|
||||
if tariff_id:
|
||||
tariff_id = _safe_int(tariff_id)
|
||||
price_kopeks = await _get_tariff_price_for_period(db, user, tariff_id, period_days)
|
||||
if price_kopeks is None:
|
||||
# Тариф недоступен или период отсутствует - используем сохранённую цену как fallback
|
||||
price_kopeks = _safe_int(
|
||||
cart_data.get("total_price")
|
||||
or cart_data.get("price")
|
||||
or cart_data.get("final_price"),
|
||||
)
|
||||
logger.warning(
|
||||
"🔁 Автопокупка: не удалось пересчитать цену тарифа %s, используем сохранённую: %s",
|
||||
tariff_id,
|
||||
price_kopeks,
|
||||
)
|
||||
else:
|
||||
price_kopeks = _safe_int(
|
||||
cart_data.get("total_price")
|
||||
or cart_data.get("price")
|
||||
or cart_data.get("final_price"),
|
||||
)
|
||||
|
||||
if price_kopeks <= 0:
|
||||
logger.warning(
|
||||
"🔁 Автопокупка: некорректная цена продления (%s) у пользователя %s",
|
||||
@@ -173,7 +254,14 @@ async def _prepare_auto_extend_context(
|
||||
)
|
||||
return None
|
||||
|
||||
description = cart_data.get("description") or f"Продление подписки на {period_days} дней"
|
||||
# Формируем описание с учётом тарифа
|
||||
if tariff_id:
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
tariff_name = tariff.name if tariff else "тариф"
|
||||
description = cart_data.get("description") or f"Продление тарифа {tariff_name} на {period_days} дней"
|
||||
else:
|
||||
description = cart_data.get("description") or f"Продление подписки на {period_days} дней"
|
||||
|
||||
device_limit = cart_data.get("device_limit")
|
||||
if device_limit is not None:
|
||||
@@ -697,7 +785,7 @@ async def auto_activate_subscription_after_topup(
|
||||
server_ids = await get_server_ids_by_uuids(db, connected_squads) if connected_squads else []
|
||||
|
||||
balance = user.balance_kopeks
|
||||
available_periods = sorted([int(p) for p in settings.AVAILABLE_SUBSCRIPTION_PERIODS], reverse=True)
|
||||
available_periods = sorted(settings.get_available_subscription_periods(), reverse=True)
|
||||
|
||||
if not available_periods:
|
||||
logger.warning("🔁 Автоактивация: нет доступных периодов подписки")
|
||||
|
||||
@@ -331,6 +331,11 @@ class SubscriptionRenewalService:
|
||||
if devices_limit is None:
|
||||
devices_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
# Модем добавляет +1 к device_limit, но оплачивается отдельно,
|
||||
# поэтому не должен учитываться как платное устройство при продлении
|
||||
if getattr(subscription, 'modem_enabled', False):
|
||||
devices_limit = max(1, devices_limit - 1)
|
||||
|
||||
total_cost, details = await calculate_subscription_total_cost(
|
||||
db,
|
||||
period_days,
|
||||
|
||||
@@ -396,11 +396,23 @@ class SubscriptionService:
|
||||
await api.disable_user(user_uuid)
|
||||
logger.info(f"✅ Отключен RemnaWave пользователь {user_uuid}")
|
||||
return True
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отключения RemnaWave пользователя: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def enable_remnawave_user(self, user_uuid: str) -> bool:
|
||||
"""Включить пользователя в RemnaWave (реактивация)."""
|
||||
try:
|
||||
async with self.get_api_client() as api:
|
||||
await api.enable_user(user_uuid)
|
||||
logger.info(f"✅ Включен RemnaWave пользователь {user_uuid}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка включения RemnaWave пользователя: {e}")
|
||||
return False
|
||||
|
||||
async def revoke_subscription(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -720,6 +732,11 @@ class SubscriptionService:
|
||||
else:
|
||||
device_limit = forced_limit
|
||||
|
||||
# Модем добавляет +1 к device_limit, но оплачивается отдельно,
|
||||
# поэтому не должен учитываться как платное устройство при продлении
|
||||
if getattr(subscription, 'modem_enabled', False):
|
||||
device_limit = max(1, device_limit - 1)
|
||||
|
||||
devices_price = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
|
||||
@@ -84,6 +84,7 @@ class BotConfigurationService:
|
||||
"CRYPTOBOT": "🪙 CryptoBot",
|
||||
"HELEKET": "🪙 Heleket",
|
||||
"CLOUDPAYMENTS": "💳 CloudPayments",
|
||||
"FREEKASSA": "💳 Freekassa",
|
||||
"YOOKASSA": "🟣 YooKassa",
|
||||
"PLATEGA": "💳 {platega_name}",
|
||||
"TRIBUTE": "🎁 Tribute",
|
||||
@@ -140,6 +141,7 @@ class BotConfigurationService:
|
||||
"CRYPTOBOT": "CryptoBot и криптоплатежи через Telegram.",
|
||||
"HELEKET": "Heleket: криптоплатежи, ключи мерчанта и вебхуки.",
|
||||
"CLOUDPAYMENTS": "CloudPayments: оплата банковскими картами, Public ID, API Secret и вебхуки.",
|
||||
"FREEKASSA": "Freekassa: ID магазина, API ключ, секретные слова и вебхуки.",
|
||||
"PLATEGA": "{platega_name}: merchant ID, секрет, ссылки возврата и методы оплаты.",
|
||||
"MULENPAY": "Платежи {mulenpay_name} и параметры магазина.",
|
||||
"PAL24": "PAL24 / PayPalych подключения и лимиты.",
|
||||
@@ -313,6 +315,7 @@ class BotConfigurationService:
|
||||
"CRYPTOBOT_": "CRYPTOBOT",
|
||||
"HELEKET_": "HELEKET",
|
||||
"CLOUDPAYMENTS_": "CLOUDPAYMENTS",
|
||||
"FREEKASSA_": "FREEKASSA",
|
||||
"PLATEGA_": "PLATEGA",
|
||||
"MULENPAY_": "MULENPAY",
|
||||
"PAL24_": "PAL24",
|
||||
@@ -416,6 +419,12 @@ class BotConfigurationService:
|
||||
ChoiceOption(4, "4 — НДС 20%"),
|
||||
ChoiceOption(5, "5 — НДС 10/110"),
|
||||
ChoiceOption(6, "6 — НДС 20/120"),
|
||||
ChoiceOption(7, "7 — НДС 5%"),
|
||||
ChoiceOption(8, "8 — НДС 7%"),
|
||||
ChoiceOption(9, "9 — НДС 5/105"),
|
||||
ChoiceOption(10, "10 — НДС 7/107"),
|
||||
ChoiceOption(11, "11 — НДС 22%"),
|
||||
ChoiceOption(12, "12 — НДС 22/122"),
|
||||
],
|
||||
"MULENPAY_LANGUAGE": [
|
||||
ChoiceOption("ru", "🇷🇺 Русский"),
|
||||
|
||||
@@ -6,9 +6,6 @@ import logging
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from decimal import Decimal
|
||||
|
||||
import aiohttp
|
||||
|
||||
from app.config import settings
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import timedelta
|
||||
|
||||
import redis.asyncio as redis
|
||||
|
||||
@@ -9,104 +8,136 @@ from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserCartService:
|
||||
"""
|
||||
Сервис для работы с корзиной пользователя через Redis
|
||||
Сервис для работы с корзиной пользователя через Redis.
|
||||
|
||||
Использует ленивую инициализацию Redis-клиента для graceful fallback
|
||||
при недоступности Redis.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
self.redis_client = None
|
||||
self._setup_redis()
|
||||
|
||||
def _setup_redis(self):
|
||||
"""Инициализация Redis клиента"""
|
||||
self._redis_client: Optional[redis.Redis] = None
|
||||
self._initialized: bool = False
|
||||
|
||||
def _get_redis_client(self) -> Optional[redis.Redis]:
|
||||
"""Ленивая инициализация Redis клиента."""
|
||||
if self._initialized:
|
||||
return self._redis_client
|
||||
|
||||
try:
|
||||
self.redis_client = redis.from_url(settings.REDIS_URL)
|
||||
self._redis_client = redis.from_url(settings.REDIS_URL)
|
||||
self._initialized = True
|
||||
logger.debug("Redis клиент для корзины инициализирован")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка подключения к Redis: {e}")
|
||||
raise
|
||||
|
||||
async def save_user_cart(self, user_id: int, cart_data: Dict[str, Any], ttl: int = 3600) -> bool:
|
||||
logger.warning(f"Не удалось подключиться к Redis для корзины: {e}")
|
||||
self._redis_client = None
|
||||
self._initialized = True
|
||||
|
||||
return self._redis_client
|
||||
|
||||
async def save_user_cart(
|
||||
self, user_id: int, cart_data: Dict[str, Any], ttl: Optional[int] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Сохранить корзину пользователя в Redis
|
||||
|
||||
Сохранить корзину пользователя в Redis.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
cart_data: Данные корзины (параметры подписки)
|
||||
ttl: Время жизни ключа в секундах (по умолчанию 1 час)
|
||||
|
||||
ttl: Время жизни ключа в секундах (по умолчанию из settings.CART_TTL_SECONDS)
|
||||
|
||||
Returns:
|
||||
bool: Успешность сохранения
|
||||
"""
|
||||
client = self._get_redis_client()
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
key = f"user_cart:{user_id}"
|
||||
json_data = json.dumps(cart_data, ensure_ascii=False)
|
||||
await self.redis_client.setex(key, ttl, json_data)
|
||||
logger.info(f"Корзина пользователя {user_id} сохранена в Redis")
|
||||
effective_ttl = ttl if ttl is not None else settings.CART_TTL_SECONDS
|
||||
await client.setex(key, effective_ttl, json_data)
|
||||
logger.debug(f"Корзина пользователя {user_id} сохранена в Redis")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка сохранения корзины пользователя {user_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def get_user_cart(self, user_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Получить корзину пользователя из Redis
|
||||
|
||||
Получить корзину пользователя из Redis.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
|
||||
|
||||
Returns:
|
||||
dict: Данные корзины или None
|
||||
"""
|
||||
client = self._get_redis_client()
|
||||
if client is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
key = f"user_cart:{user_id}"
|
||||
json_data = await self.redis_client.get(key)
|
||||
json_data = await client.get(key)
|
||||
if json_data:
|
||||
cart_data = json.loads(json_data)
|
||||
logger.info(f"Корзина пользователя {user_id} загружена из Redis")
|
||||
logger.debug(f"Корзина пользователя {user_id} загружена из Redis")
|
||||
return cart_data
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения корзины пользователя {user_id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def delete_user_cart(self, user_id: int) -> bool:
|
||||
"""
|
||||
Удалить корзину пользователя из Redis
|
||||
|
||||
Удалить корзину пользователя из Redis.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
|
||||
|
||||
Returns:
|
||||
bool: Успешность удаления
|
||||
"""
|
||||
client = self._get_redis_client()
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
key = f"user_cart:{user_id}"
|
||||
result = await self.redis_client.delete(key)
|
||||
result = await client.delete(key)
|
||||
if result:
|
||||
logger.info(f"Корзина пользователя {user_id} удалена из Redis")
|
||||
logger.debug(f"Корзина пользователя {user_id} удалена из Redis")
|
||||
return bool(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления корзины пользователя {user_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def has_user_cart(self, user_id: int) -> bool:
|
||||
"""
|
||||
Проверить наличие корзины у пользователя
|
||||
|
||||
Проверить наличие корзины у пользователя.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
|
||||
|
||||
Returns:
|
||||
bool: Наличие корзины
|
||||
"""
|
||||
client = self._get_redis_client()
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
key = f"user_cart:{user_id}"
|
||||
exists = await self.redis_client.exists(key)
|
||||
exists = await client.exists(key)
|
||||
return bool(exists)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки наличия корзины пользователя {user_id}: {e}")
|
||||
return False
|
||||
|
||||
# Глобальный экземпляр сервиса
|
||||
user_cart_service = UserCartService()
|
||||
|
||||
# Глобальный экземпляр сервиса (инициализация Redis отложена)
|
||||
user_cart_service = UserCartService()
|
||||
|
||||
@@ -224,7 +224,7 @@ class UserService:
|
||||
db: AsyncSession,
|
||||
min_balance_kopeks: int,
|
||||
page: int = 1,
|
||||
limit: int = 20,
|
||||
limit: int = 10,
|
||||
) -> Dict[str, Any]:
|
||||
"""Возвращает пользователей с истекшей подпиской и достаточным балансом."""
|
||||
try:
|
||||
@@ -247,7 +247,7 @@ class UserService:
|
||||
.limit(limit)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
users = result.scalars().all()
|
||||
users = result.scalars().unique().all()
|
||||
|
||||
count_query = (
|
||||
select(func.count(User.id))
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
import aiohttp
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.webhook import (
|
||||
get_active_webhooks_for_event,
|
||||
record_webhook_delivery,
|
||||
update_webhook_stats,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeliveryResult:
|
||||
"""Результат доставки webhook."""
|
||||
|
||||
webhook: Any
|
||||
event_type: str
|
||||
payload: dict[str, Any]
|
||||
status: str
|
||||
response_status: Optional[int] = None
|
||||
response_body: Optional[str] = None
|
||||
error_message: Optional[str] = None
|
||||
|
||||
|
||||
class WebhookService:
|
||||
"""Сервис для отправки webhooks."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._session: Optional[aiohttp.ClientSession] = None
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
"""Получить или создать HTTP сессию."""
|
||||
if self._session is None or self._session.closed:
|
||||
timeout = aiohttp.ClientTimeout(total=10, connect=5)
|
||||
self._session = aiohttp.ClientSession(timeout=timeout)
|
||||
return self._session
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Закрыть HTTP сессию."""
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
def _sign_payload(self, payload: str, secret: str) -> str:
|
||||
"""Подписать payload с помощью секрета."""
|
||||
return hmac.new(
|
||||
secret.encode("utf-8"),
|
||||
payload.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
async def send_webhook(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
event_type: str,
|
||||
payload: dict[str, Any],
|
||||
) -> None:
|
||||
"""Отправить webhook для события."""
|
||||
webhooks = await get_active_webhooks_for_event(db, event_type)
|
||||
|
||||
if not webhooks:
|
||||
logger.debug("No active webhooks for event type: %s", event_type)
|
||||
return
|
||||
|
||||
# Выполняем HTTP запросы параллельно (без операций с БД)
|
||||
tasks = [
|
||||
self._deliver_webhook_http(webhook, event_type, payload)
|
||||
for webhook in webhooks
|
||||
]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Записываем результаты в БД последовательно (избегаем concurrent session access)
|
||||
for result in results:
|
||||
if isinstance(result, Exception):
|
||||
logger.exception("Unexpected error during webhook delivery: %s", result)
|
||||
continue
|
||||
if isinstance(result, DeliveryResult):
|
||||
await self._record_result(db, result)
|
||||
|
||||
async def _deliver_webhook_http(
|
||||
self,
|
||||
webhook: Any,
|
||||
event_type: str,
|
||||
payload: dict[str, Any],
|
||||
) -> DeliveryResult:
|
||||
"""Выполнить HTTP доставку webhook (без операций с БД)."""
|
||||
payload_json = json.dumps(payload, default=str, ensure_ascii=False)
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Webhook-Event": event_type,
|
||||
"X-Webhook-Id": str(webhook.id),
|
||||
}
|
||||
|
||||
# Добавляем подпись, если есть секрет
|
||||
if webhook.secret:
|
||||
signature = self._sign_payload(payload_json, webhook.secret)
|
||||
headers["X-Webhook-Signature"] = f"sha256={signature}"
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.post(
|
||||
webhook.url,
|
||||
data=payload_json,
|
||||
headers=headers,
|
||||
) as response:
|
||||
response_body = await response.text()
|
||||
# Ограничиваем размер ответа для хранения
|
||||
if len(response_body) > 1000:
|
||||
response_body = response_body[:1000] + "... (truncated)"
|
||||
|
||||
status = "success" if 200 <= response.status < 300 else "failed"
|
||||
error_message = None
|
||||
if status == "failed":
|
||||
error_message = f"HTTP {response.status}: {response_body[:500]}"
|
||||
|
||||
return DeliveryResult(
|
||||
webhook=webhook,
|
||||
event_type=event_type,
|
||||
payload=payload,
|
||||
status=status,
|
||||
response_status=response.status,
|
||||
response_body=response_body,
|
||||
error_message=error_message,
|
||||
)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
return DeliveryResult(
|
||||
webhook=webhook,
|
||||
event_type=event_type,
|
||||
payload=payload,
|
||||
status="failed",
|
||||
error_message="Request timeout",
|
||||
)
|
||||
|
||||
except Exception as error:
|
||||
return DeliveryResult(
|
||||
webhook=webhook,
|
||||
event_type=event_type,
|
||||
payload=payload,
|
||||
status="failed",
|
||||
error_message=str(error),
|
||||
)
|
||||
|
||||
async def _record_result(self, db: AsyncSession, result: DeliveryResult) -> None:
|
||||
"""Записать результат доставки в БД (последовательно)."""
|
||||
try:
|
||||
await record_webhook_delivery(
|
||||
db,
|
||||
webhook_id=result.webhook.id,
|
||||
event_type=result.event_type,
|
||||
payload=result.payload,
|
||||
status=result.status,
|
||||
response_status=result.response_status,
|
||||
response_body=result.response_body,
|
||||
error_message=result.error_message,
|
||||
)
|
||||
|
||||
await update_webhook_stats(db, result.webhook, result.status == "success")
|
||||
|
||||
if result.status == "success":
|
||||
logger.info(
|
||||
"Webhook %s delivered successfully to %s",
|
||||
result.webhook.id,
|
||||
result.webhook.url,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Webhook %s delivery failed: %s",
|
||||
result.webhook.id,
|
||||
result.error_message,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.exception(
|
||||
"Failed to record webhook delivery result for %s: %s",
|
||||
result.webhook.id,
|
||||
error,
|
||||
)
|
||||
|
||||
|
||||
# Глобальный экземпляр сервиса
|
||||
webhook_service = WebhookService()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user