Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60305d8d5b | |||
| 07ef3b46d9 | |||
| f9d58e964c | |||
| d3c14ac303 | |||
| fda9f3beec | |||
| 27365b3c75 | |||
| 3dac332a9f | |||
| c5124b97b6 | |||
| ee2e79db31 | |||
| d05ff678ab | |||
| fcaa9dfb27 | |||
| c30c2feee1 | |||
| 640da34736 | |||
| 93bb8e0eb4 | |||
| 7d9ced8f4f | |||
| b5998ea9d2 | |||
| aabadf1ffd | |||
| e5e5bb3354 | |||
| ea41b0af7a | |||
| 3193ffbd1b | |||
| 5da01cc6df | |||
| 887ea9cf5a | |||
| bee4aa4284 | |||
| b167ed3dd1 | |||
| 6cec024e46 | |||
| 2094886990 | |||
| b0fd38d60c | |||
| 3a680b41b0 | |||
| 02e40bd6f7 | |||
| 19dabf3851 | |||
| eaf3a07579 | |||
| be1da976e1 | |||
| a1ffd5bda6 | |||
| d58a80f3ea | |||
| 45c7afe34c | |||
| e43a8d6ce4 | |||
| e94b93d0c1 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.10.0"
|
||||
".": "3.10.2"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,48 @@
|
||||
# Changelog
|
||||
|
||||
## [3.10.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.1...v3.10.2) (2026-02-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* allow email change for unverified emails ([93bb8e0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/93bb8e0eb492ca59e29da86594e84e9c486fea65))
|
||||
* clean stale squad UUIDs from tariffs during server sync ([fcaa9df](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fcaa9dfb27350ceda3765c6980ad67f671477caf))
|
||||
* delete subscription_servers before subscription to prevent FK violation ([7d9ced8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7d9ced8f4f71b43ed4ac798e6ff904a086e1ac4a))
|
||||
* handle StaleDataError in webhook user.deleted server counter decrement ([c30c2fe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c30c2feee1db03f0a359b291117da88002dd0fe0))
|
||||
* handle time/date types in backup JSON serialization ([27365b3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/27365b3c7518c09229afcd928f505d0f3f66213f))
|
||||
* HTML parse fallback, email change race condition, username length limit ([d05ff67](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d05ff678abfacaa7e55ad3e55f226d706d32a7b7))
|
||||
* payment race conditions, balance atomicity, renewal rollback safety ([c5124b9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c5124b97b63eda59b52d2cbf9e2dcdaa6141ed6e))
|
||||
* remove DisplayNameRestrictionMiddleware ([640da34](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/640da3473662cfdcceaa4346729467600ac3b14f))
|
||||
* suppress bot-blocked-by-user error in AuthMiddleware ([fda9f3b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fda9f3beecbfcca4d7abc16cf661d5ad5e3b5141))
|
||||
* UnboundLocalError for get_logo_media in required_sub_channel_check ([d3c14ac](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d3c14ac30363839d1340129f279a7a7b4b021ed1))
|
||||
* use traffic topup config and add WATA 429 retry ([b5998ea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b5998ea9d22644ed2914b0e829b3a76a32a69ddf))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* remove modem functionality from classic subscriptions ([ee2e79d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ee2e79db3114fe7a9852d2cd33c4b4fbbde311ea))
|
||||
|
||||
## [3.10.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.0...v3.10.1) (2026-02-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* address review issues in backup, updates, and webhook handlers ([2094886](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/20948869902dc570681b05709ac8d51996330a6e))
|
||||
* allow purchase when recalculated price is lower than cached ([19dabf3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/19dabf38512ae0c2121108d0b92fc8f384292484))
|
||||
* change CryptoBot URL priority to bot_invoice_url for Telegram opening ([3193ffb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3193ffbd1bee07cb79824d87cb0f77b473b22989))
|
||||
* clear subscription data when user deleted from Remnawave panel ([b0fd38d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b0fd38d60c22247a0086c570665b92c73a060f2f))
|
||||
* downgrade Telegram timeout errors to warning in monitoring service ([e43a8d6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e43a8d6ce4c40a7212bf90644f82da109717bdcb))
|
||||
* expand backup coverage to all 68 models and harden restore ([02e40bd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/02e40bd6f7ef8e653cae53ccd127f2f79009e0d4))
|
||||
* handle nullable traffic_limit_gb and end_date in subscription model ([e94b93d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e94b93d0c10b4e61d7750ca47e1b2f888f5873ed))
|
||||
* handle StaleDataError in webhook when user already deleted ([d58a80f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d58a80f3eaa64a6fc899e10b3b14584fb7fc18a9))
|
||||
* ignore 'message is not modified' on privacy policy decline ([be1da97](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/be1da976e14a35e6cca01a7fca7529c55c1a208b))
|
||||
* preserve purchased traffic when extending same tariff ([b167ed3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b167ed3dd1c6e6239db2bdbb8424bcb1fb7715d9))
|
||||
* prevent cascading greenlet errors after sync rollback ([a1ffd5b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a1ffd5bda6b63145104ce750835d8e6492d781dc))
|
||||
* protect server counter callers and fix tariff change detection ([bee4aa4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bee4aa42842b8b6611c7c268bcfced408a227bc0))
|
||||
* suppress 'message is not modified' error in updates panel ([3a680b4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3a680b41b0124848572809d187cab720e1db8506))
|
||||
* use callback fallback when MINIAPP_CUSTOM_URL is not set ([eaf3a07](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eaf3a07579729031030308d77f61a5227b796c02))
|
||||
* use flush instead of commit in server counter functions ([6cec024](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6cec024e46ef9177cb59aa81590953c9a75d81bb))
|
||||
|
||||
## [3.10.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.9.1...v3.10.0) (2026-02-10)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.10.0" # x-release-please-version
|
||||
ARG VERSION="v3.10.2" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -1118,11 +1118,10 @@ openssl rand -hex 32
|
||||
|
||||
**2. Настройка в панели Remnawave:**
|
||||
|
||||
В панели Remnawave перейдите в раздел **Настройки > Вебхуки** и создайте новый вебхук:
|
||||
В env панели Remnawave заполните:
|
||||
|
||||
- **URL**: `https://hooks.domain.com/remnawave-webhook`
|
||||
- **Secret**: тот же секрет, что и в `REMNAWAVE_WEBHOOK_SECRET`
|
||||
- **Events**: выберите нужные события или все
|
||||
|
||||
**3. Настройка прокси:**
|
||||
|
||||
|
||||
@@ -63,7 +63,6 @@ from app.handlers.stars_payments import register_stars_handlers
|
||||
from app.middlewares.auth import AuthMiddleware
|
||||
from app.middlewares.blacklist import BlacklistMiddleware
|
||||
from app.middlewares.button_stats import ButtonStatsMiddleware
|
||||
from app.middlewares.display_name_restriction import DisplayNameRestrictionMiddleware
|
||||
from app.middlewares.global_error import GlobalErrorMiddleware
|
||||
from app.middlewares.logging import LoggingMiddleware
|
||||
from app.middlewares.maintenance import MaintenanceMiddleware
|
||||
@@ -124,10 +123,6 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
dp.message.middleware(blacklist_middleware)
|
||||
dp.callback_query.middleware(blacklist_middleware)
|
||||
dp.pre_checkout_query.middleware(blacklist_middleware)
|
||||
display_name_middleware = DisplayNameRestrictionMiddleware()
|
||||
dp.message.middleware(display_name_middleware)
|
||||
dp.callback_query.middleware(display_name_middleware)
|
||||
dp.pre_checkout_query.middleware(display_name_middleware)
|
||||
dp.message.middleware(ThrottlingMiddleware())
|
||||
dp.callback_query.middleware(ThrottlingMiddleware())
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ from app.database.crud.user import (
|
||||
from app.database.models import (
|
||||
PromoGroup,
|
||||
Subscription,
|
||||
SubscriptionServer,
|
||||
SubscriptionStatus,
|
||||
TrafficPurchase,
|
||||
Transaction,
|
||||
@@ -1806,6 +1807,8 @@ async def reset_user_trial(
|
||||
# Delete subscription from database
|
||||
from sqlalchemy import delete
|
||||
|
||||
subscription_id = user.subscription.id
|
||||
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == subscription_id))
|
||||
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
|
||||
subscription_deleted = True
|
||||
|
||||
@@ -1876,6 +1879,8 @@ async def reset_user_subscription(
|
||||
# Delete subscription from database
|
||||
from sqlalchemy import delete
|
||||
|
||||
subscription_id = user.subscription.id
|
||||
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == subscription_id))
|
||||
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
|
||||
subscription_deleted = True
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
@@ -969,14 +970,13 @@ async def request_email_change(
|
||||
"""
|
||||
Request email change.
|
||||
|
||||
Sends a 6-digit verification code to the new email address.
|
||||
User must have a verified email to change it.
|
||||
For verified emails: sends a 6-digit verification code to the new email.
|
||||
For unverified emails: replaces the email directly and sends verification to the new address.
|
||||
"""
|
||||
# Check if user has a verified email
|
||||
if not user.email or not user.email_verified:
|
||||
if not user.email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='You must have a verified email to change it',
|
||||
detail='No email address to change',
|
||||
)
|
||||
|
||||
# Check if new email is the same as current
|
||||
@@ -1000,6 +1000,68 @@ async def request_email_change(
|
||||
detail='This email is already registered',
|
||||
)
|
||||
|
||||
# Unverified email: replace directly and send verification to new address
|
||||
if not user.email_verified:
|
||||
old_email = user.email
|
||||
user.email = request.new_email.lower()
|
||||
user.email_verified = False
|
||||
|
||||
verification_token = generate_verification_token()
|
||||
verification_expires = get_verification_expires_at()
|
||||
user.email_verification_token = verification_token
|
||||
user.email_verification_expires = verification_expires
|
||||
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='This email is already registered',
|
||||
)
|
||||
|
||||
if settings.is_cabinet_email_verification_enabled() and email_service.is_configured():
|
||||
cabinet_url = settings.CABINET_URL
|
||||
verification_url = f'{cabinet_url}/verify-email'
|
||||
lang = user.language or 'ru'
|
||||
full_url = f'{verification_url}?token={verification_token}'
|
||||
expire_hours = settings.get_cabinet_email_verification_expire_hours()
|
||||
|
||||
override = await get_rendered_override(
|
||||
'email_verification',
|
||||
lang,
|
||||
context={
|
||||
'username': user.first_name or '',
|
||||
'verification_url': full_url,
|
||||
'expire_hours': str(expire_hours),
|
||||
},
|
||||
db=db,
|
||||
)
|
||||
custom_subject, custom_body = override if override else (None, None)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
email_service.send_verification_email,
|
||||
to_email=request.new_email,
|
||||
verification_token=verification_token,
|
||||
verification_url=verification_url,
|
||||
username=user.first_name,
|
||||
language=lang,
|
||||
custom_subject=custom_subject,
|
||||
custom_body_html=custom_body,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send verification email to {request.new_email} for user {user.id}: {e}')
|
||||
|
||||
logger.info(f'Unverified email replaced for user {user.id}: {old_email} -> {request.new_email}')
|
||||
|
||||
return EmailChangeResponse(
|
||||
message='Email replaced, verification sent to new address',
|
||||
new_email=request.new_email,
|
||||
expires_in_minutes=0,
|
||||
)
|
||||
|
||||
# Verified email: send code to new address for confirmation
|
||||
# Generate verification code
|
||||
code = generate_email_change_code()
|
||||
expires_at = get_email_change_expires_at()
|
||||
|
||||
@@ -420,9 +420,9 @@ async def create_topup(
|
||||
)
|
||||
if result:
|
||||
payment_url = (
|
||||
result.get('web_app_invoice_url')
|
||||
result.get('bot_invoice_url')
|
||||
or result.get('mini_app_invoice_url')
|
||||
or result.get('bot_invoice_url')
|
||||
or result.get('web_app_invoice_url')
|
||||
)
|
||||
payment_id = result.get('invoice_id') or str(result.get('local_payment_id', 'pending'))
|
||||
else:
|
||||
|
||||
@@ -619,7 +619,7 @@ async def get_traffic_packages(
|
||||
if tariff and not tariff.allow_traffic_topup:
|
||||
return []
|
||||
|
||||
packages = settings.get_traffic_packages()
|
||||
packages = settings.get_traffic_topup_packages()
|
||||
result = []
|
||||
|
||||
for pkg in packages:
|
||||
@@ -724,7 +724,7 @@ async def purchase_traffic(
|
||||
)
|
||||
|
||||
# Получаем цену из глобальных настроек
|
||||
packages = settings.get_traffic_packages()
|
||||
packages = settings.get_traffic_topup_packages()
|
||||
matching_pkg = next((pkg for pkg in packages if pkg['gb'] == request.gb and pkg.get('enabled', True)), None)
|
||||
if not matching_pkg:
|
||||
raise HTTPException(
|
||||
@@ -2407,7 +2407,7 @@ async def save_traffic_cart(
|
||||
detail='Докупка трафика отключена',
|
||||
)
|
||||
|
||||
packages = settings.get_traffic_packages()
|
||||
packages = settings.get_traffic_topup_packages()
|
||||
matching_pkg = next((pkg for pkg in packages if pkg['gb'] == request.gb and pkg.get('enabled', True)), None)
|
||||
if not matching_pkg:
|
||||
raise HTTPException(
|
||||
@@ -3216,7 +3216,10 @@ async def update_countries(
|
||||
added_server_ids = await get_server_ids_by_uuids(db, added)
|
||||
if added_server_ids:
|
||||
await add_subscription_servers(db, user.subscription, added_server_ids, added_server_prices)
|
||||
await add_user_to_servers(db, added_server_ids)
|
||||
try:
|
||||
await add_user_to_servers(db, added_server_ids)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка обновления счётчика серверов: {e}')
|
||||
|
||||
# Update connected squads
|
||||
user.subscription.connected_squads = selected_countries
|
||||
|
||||
+1
-65
@@ -167,11 +167,6 @@ class Settings(BaseSettings):
|
||||
DEVICES_SELECTION_ENABLED: bool = True
|
||||
DEVICES_SELECTION_DISABLED_AMOUNT: int | None = None
|
||||
|
||||
# Настройки модема
|
||||
MODEM_ENABLED: bool = False
|
||||
MODEM_PRICE_PER_MONTH: int = 10000 # Цена модема в копейках за месяц
|
||||
MODEM_PERIOD_DISCOUNTS: str = '' # Скидки на модем: "месяцев:процент,месяцев:процент" (напр. "3:10,6:15,12:20")
|
||||
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED: bool = False
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS: str = ''
|
||||
|
||||
@@ -1057,7 +1052,7 @@ class Settings(BaseSettings):
|
||||
if not sanitized_username:
|
||||
sanitized_username = f'user_{identifier}'
|
||||
|
||||
return sanitized_username[:64]
|
||||
return sanitized_username[:36]
|
||||
|
||||
@staticmethod
|
||||
def parse_daily_time_list(raw_value: str | None) -> list[time]:
|
||||
@@ -1529,9 +1524,6 @@ class Settings(BaseSettings):
|
||||
def get_disabled_mode_device_limit(self) -> int | None:
|
||||
return self.get_devices_selection_disabled_amount()
|
||||
|
||||
def is_modem_enabled(self) -> bool:
|
||||
return bool(self.MODEM_ENABLED)
|
||||
|
||||
def is_tariffs_mode(self) -> bool:
|
||||
"""Проверяет, включен ли режим продаж 'Тарифы'."""
|
||||
return self.SALES_MODE == 'tariffs'
|
||||
@@ -1548,62 +1540,6 @@ class Settings(BaseSettings):
|
||||
"""Возвращает ID тарифа для триала (0 = использовать стандартные настройки)."""
|
||||
return max(0, self.TRIAL_TARIFF_ID)
|
||||
|
||||
def get_modem_price_per_month(self) -> int:
|
||||
try:
|
||||
value = int(self.MODEM_PRICE_PER_MONTH)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
'Некорректное значение MODEM_PRICE_PER_MONTH: %s',
|
||||
self.MODEM_PRICE_PER_MONTH,
|
||||
)
|
||||
return 10000
|
||||
return max(0, value)
|
||||
|
||||
def get_modem_period_discounts(self) -> dict[int, int]:
|
||||
"""Возвращает скидки на модем по количеству месяцев: {месяцев: процент_скидки}"""
|
||||
try:
|
||||
config_str = (self.MODEM_PERIOD_DISCOUNTS or '').strip()
|
||||
if not config_str:
|
||||
return {}
|
||||
|
||||
discounts: dict[int, int] = {}
|
||||
for part in config_str.split(','):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
|
||||
months_and_discount = part.split(':')
|
||||
if len(months_and_discount) != 2:
|
||||
continue
|
||||
|
||||
months_str, discount_str = months_and_discount
|
||||
try:
|
||||
months = int(months_str.strip())
|
||||
discount_percent = int(discount_str.strip())
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
discounts[months] = max(0, min(100, discount_percent))
|
||||
|
||||
return discounts
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def get_modem_period_discount(self, months: int) -> int:
|
||||
"""Возвращает процент скидки для указанного количества месяцев"""
|
||||
if months <= 0:
|
||||
return 0
|
||||
|
||||
discounts = self.get_modem_period_discounts()
|
||||
|
||||
# Ищем точное совпадение или ближайшее меньшее
|
||||
applicable_discount = 0
|
||||
for discount_months, discount_percent in sorted(discounts.items()):
|
||||
if months >= discount_months:
|
||||
applicable_discount = discount_percent
|
||||
|
||||
return applicable_discount
|
||||
|
||||
def is_trial_paid_activation_enabled(self) -> bool:
|
||||
# TRIAL_PAYMENT_ENABLED - главный переключатель платной активации
|
||||
# Если выключен - триал бесплатный, независимо от цены
|
||||
|
||||
@@ -23,6 +23,7 @@ from app.database.models import (
|
||||
Subscription,
|
||||
SubscriptionServer,
|
||||
SubscriptionStatus,
|
||||
Tariff,
|
||||
User,
|
||||
)
|
||||
|
||||
@@ -362,6 +363,25 @@ async def sync_with_remnawave(db: AsyncSession, remnawave_squads: list[dict]) ->
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
cleaned_subscriptions += 1
|
||||
|
||||
# Clean up stale UUIDs from tariff allowed_squads
|
||||
cleaned_tariffs = 0
|
||||
tariffs_result = await db.execute(select(Tariff))
|
||||
for tariff in tariffs_result.scalars().all():
|
||||
current = list(tariff.allowed_squads or [])
|
||||
if not current:
|
||||
continue
|
||||
filtered = [u for u in current if u not in removed_uuids]
|
||||
if len(filtered) != len(current):
|
||||
tariff.allowed_squads = filtered
|
||||
tariff.updated_at = datetime.utcnow()
|
||||
cleaned_tariffs += 1
|
||||
logger.info(
|
||||
'🧹 Тариф "%s" (ID: %s): удалены несуществующие сквады %s',
|
||||
tariff.name,
|
||||
tariff.id,
|
||||
[u for u in current if u in removed_uuids],
|
||||
)
|
||||
|
||||
await db.execute(delete(ServerSquad).where(ServerSquad.id.in_(removed_ids)))
|
||||
removed = len(removed_servers)
|
||||
|
||||
@@ -371,6 +391,12 @@ async def sync_with_remnawave(db: AsyncSession, remnawave_squads: list[dict]) ->
|
||||
cleaned_subscriptions,
|
||||
)
|
||||
|
||||
if cleaned_tariffs:
|
||||
logger.info(
|
||||
'🧹 Обновлены тарифы после удаления серверов: %s',
|
||||
cleaned_tariffs,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f'🔄 Синхронизация завершена: +{created} ~{updated} -{removed}')
|
||||
@@ -740,14 +766,13 @@ async def add_user_to_servers(db: AsyncSession, server_squad_ids: list[int]) ->
|
||||
.values(current_users=ServerSquad.current_users + 1)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.flush()
|
||||
logger.info(f'✅ Увеличен счетчик пользователей для серверов: {server_squad_ids}')
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка увеличения счетчика пользователей: {e}')
|
||||
await db.rollback()
|
||||
return False
|
||||
raise
|
||||
|
||||
|
||||
async def remove_user_from_servers(db: AsyncSession, server_squad_ids: list[int]) -> bool:
|
||||
@@ -759,14 +784,13 @@ async def remove_user_from_servers(db: AsyncSession, server_squad_ids: list[int]
|
||||
.values(current_users=func.greatest(ServerSquad.current_users - 1, 0))
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.flush()
|
||||
logger.info(f'✅ Уменьшен счетчик пользователей для серверов: {server_squad_ids}')
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка уменьшения счетчика пользователей: {e}')
|
||||
await db.rollback()
|
||||
return False
|
||||
raise
|
||||
|
||||
|
||||
async def get_server_ids_by_uuids(db: AsyncSession, squad_uuids: list[str]) -> list[int]:
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional
|
||||
from sqlalchemy import and_, delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.orm.exc import StaleDataError
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.notification import clear_notifications
|
||||
@@ -357,9 +358,8 @@ async def extend_subscription(
|
||||
)
|
||||
|
||||
# Определяем, происходит ли СМЕНА тарифа (а не продление того же)
|
||||
is_tariff_change = (
|
||||
tariff_id is not None and subscription.tariff_id is not None and tariff_id != subscription.tariff_id
|
||||
)
|
||||
# Включает переход из классического режима (tariff_id=None) в тарифный
|
||||
is_tariff_change = tariff_id is not None and (subscription.tariff_id is None or tariff_id != subscription.tariff_id)
|
||||
|
||||
if is_tariff_change:
|
||||
logger.info(f'🔄 Обнаружена СМЕНА тарифа: {subscription.tariff_id} → {tariff_id}')
|
||||
@@ -440,17 +440,28 @@ async def extend_subscription(
|
||||
|
||||
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
|
||||
# Сбрасываем все докупки трафика при смене тарифа
|
||||
from sqlalchemy import delete as sql_delete
|
||||
|
||||
from app.database.models import TrafficPurchase
|
||||
if is_tariff_change:
|
||||
# При СМЕНЕ тарифа сбрасываем все докупки трафика
|
||||
subscription.traffic_limit_gb = traffic_limit_gb
|
||||
from sqlalchemy import delete as sql_delete
|
||||
|
||||
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
|
||||
subscription.purchased_traffic_gb = 0
|
||||
subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика
|
||||
logger.info(f'📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb} ГБ (все докупки сброшены)')
|
||||
from app.database.models import TrafficPurchase
|
||||
|
||||
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
|
||||
subscription.purchased_traffic_gb = 0
|
||||
subscription.traffic_reset_at = None
|
||||
logger.info(
|
||||
f'📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb} ГБ (смена тарифа, докупки сброшены)'
|
||||
)
|
||||
else:
|
||||
# При ПРОДЛЕНИИ того же тарифа — сохраняем докупленный трафик
|
||||
purchased = subscription.purchased_traffic_gb or 0
|
||||
subscription.traffic_limit_gb = traffic_limit_gb + purchased
|
||||
logger.info(
|
||||
f'📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb + purchased} ГБ (докупки сохранены: {purchased} ГБ)'
|
||||
)
|
||||
elif settings.RESET_TRAFFIC_ON_PAYMENT:
|
||||
subscription.traffic_used_gb = 0.0
|
||||
# В режиме тарифов сохраняем докупленный трафик при продлении
|
||||
@@ -615,6 +626,9 @@ async def decrement_subscription_server_counts(
|
||||
if not subscription:
|
||||
return
|
||||
|
||||
# Save ID before any DB operations that might invalidate the ORM object
|
||||
sub_id = subscription.id
|
||||
|
||||
server_ids: set[int] = set()
|
||||
|
||||
if subscription_servers is not None:
|
||||
@@ -623,12 +637,12 @@ async def decrement_subscription_server_counts(
|
||||
server_ids.add(sub_server.server_squad_id)
|
||||
else:
|
||||
try:
|
||||
ids_from_links = await get_subscription_server_ids(db, subscription.id)
|
||||
ids_from_links = await get_subscription_server_ids(db, sub_id)
|
||||
server_ids.update(ids_from_links)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'⚠️ Не удалось получить серверы подписки %s для уменьшения счетчика: %s',
|
||||
subscription.id,
|
||||
sub_id,
|
||||
error,
|
||||
)
|
||||
|
||||
@@ -642,7 +656,7 @@ async def decrement_subscription_server_counts(
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'⚠️ Не удалось сопоставить сквады подписки %s с серверами: %s',
|
||||
subscription.id,
|
||||
sub_id,
|
||||
error,
|
||||
)
|
||||
|
||||
@@ -652,12 +666,20 @@ async def decrement_subscription_server_counts(
|
||||
try:
|
||||
from app.database.crud.server_squad import remove_user_from_servers
|
||||
|
||||
await remove_user_from_servers(db, sorted(server_ids))
|
||||
# Use savepoint so StaleDataError rollback doesn't affect the parent transaction
|
||||
async with db.begin_nested():
|
||||
await remove_user_from_servers(db, sorted(server_ids))
|
||||
except StaleDataError:
|
||||
logger.warning(
|
||||
'⚠️ Подписка %s уже удалена (StaleDataError), пропускаем декремент серверов %s',
|
||||
sub_id,
|
||||
list(server_ids),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'⚠️ Ошибка уменьшения счетчика пользователей серверов %s для подписки %s: %s',
|
||||
list(server_ids),
|
||||
subscription.id,
|
||||
sub_id,
|
||||
error,
|
||||
)
|
||||
|
||||
|
||||
@@ -503,6 +503,10 @@ async def subtract_user_balance(
|
||||
logger.info(f' 💸 Сумма к списанию: {amount_kopeks} копеек')
|
||||
logger.info(f' 📝 Описание: {description}')
|
||||
|
||||
# Lock the user row to prevent concurrent balance race conditions
|
||||
locked_result = await db.execute(select(User).where(User.id == user.id).with_for_update())
|
||||
user = locked_result.scalar_one()
|
||||
|
||||
log_context: dict[str, object] | None = None
|
||||
if consume_promo_offer:
|
||||
try:
|
||||
@@ -554,14 +558,13 @@ async def subtract_user_balance(
|
||||
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if create_transaction:
|
||||
from app.database.crud.transaction import (
|
||||
create_transaction as create_trans,
|
||||
)
|
||||
|
||||
# create_trans commits the session, atomically persisting
|
||||
# both the balance change and the transaction record
|
||||
await create_trans(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
@@ -570,6 +573,10 @@ async def subtract_user_balance(
|
||||
description=description,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
else:
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
if consume_promo_offer and log_context:
|
||||
try:
|
||||
|
||||
+18
-9
@@ -1159,17 +1159,25 @@ class Subscription(Base):
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
current_time = datetime.utcnow()
|
||||
return self.status == SubscriptionStatus.ACTIVE.value and self.end_date > current_time
|
||||
return (
|
||||
self.status == SubscriptionStatus.ACTIVE.value
|
||||
and self.end_date is not None
|
||||
and self.end_date > current_time
|
||||
)
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""Проверяет, истёк ли срок подписки"""
|
||||
return self.end_date <= datetime.utcnow()
|
||||
return self.end_date is not None and self.end_date <= datetime.utcnow()
|
||||
|
||||
@property
|
||||
def should_be_expired(self) -> bool:
|
||||
current_time = datetime.utcnow()
|
||||
return self.status == SubscriptionStatus.ACTIVE.value and self.end_date <= current_time
|
||||
return (
|
||||
self.status == SubscriptionStatus.ACTIVE.value
|
||||
and self.end_date is not None
|
||||
and self.end_date <= current_time
|
||||
)
|
||||
|
||||
@property
|
||||
def actual_status(self) -> str:
|
||||
@@ -1182,12 +1190,12 @@ class Subscription(Base):
|
||||
return 'disabled'
|
||||
|
||||
if self.status == SubscriptionStatus.ACTIVE.value:
|
||||
if self.end_date <= current_time:
|
||||
if self.end_date is None or self.end_date <= current_time:
|
||||
return 'expired'
|
||||
return 'active'
|
||||
|
||||
if self.status == SubscriptionStatus.TRIAL.value:
|
||||
if self.end_date <= current_time:
|
||||
if self.end_date is None or self.end_date <= current_time:
|
||||
return 'expired'
|
||||
return 'trial'
|
||||
|
||||
@@ -1230,6 +1238,8 @@ class Subscription(Base):
|
||||
|
||||
@property
|
||||
def days_left(self) -> int:
|
||||
if self.end_date is None:
|
||||
return 0
|
||||
current_time = datetime.utcnow()
|
||||
if self.end_date <= current_time:
|
||||
return 0
|
||||
@@ -1255,11 +1265,10 @@ class Subscription(Base):
|
||||
|
||||
@property
|
||||
def traffic_used_percent(self) -> float:
|
||||
if self.traffic_limit_gb == 0:
|
||||
if not self.traffic_limit_gb:
|
||||
return 0.0
|
||||
if self.traffic_limit_gb > 0:
|
||||
return min((self.traffic_used_gb / self.traffic_limit_gb) * 100, 100.0)
|
||||
return 0.0
|
||||
used = self.traffic_used_gb or 0.0
|
||||
return min((used / self.traffic_limit_gb) * 100, 100.0)
|
||||
|
||||
def extend_subscription(self, days: int):
|
||||
if self.end_date > datetime.utcnow():
|
||||
|
||||
@@ -69,6 +69,10 @@ async def show_updates_menu(callback: types.CallbackQuery, db_user: User, db: As
|
||||
await callback.answer()
|
||||
|
||||
except Exception as e:
|
||||
if 'message is not modified' in str(e).lower():
|
||||
logger.debug('📝 Сообщение не изменено в show_updates_menu')
|
||||
await callback.answer()
|
||||
return
|
||||
logger.error(f'Ошибка показа меню обновлений: {e}')
|
||||
await callback.answer('❌ Ошибка загрузки меню обновлений', show_alert=True)
|
||||
|
||||
@@ -118,6 +122,9 @@ async def check_updates(callback: types.CallbackQuery, db_user: User, db: AsyncS
|
||||
await callback.message.edit_text(message, reply_markup=keyboard, parse_mode='HTML')
|
||||
|
||||
except Exception as e:
|
||||
if 'message is not modified' in str(e).lower():
|
||||
logger.debug('📝 Сообщение не изменено в check_updates')
|
||||
return
|
||||
logger.error(f'Ошибка проверки обновлений: {e}')
|
||||
await callback.message.edit_text(
|
||||
f'❌ <b>ОШИБКА ПРОВЕРКИ ОБНОВЛЕНИЙ</b>\n\n'
|
||||
@@ -142,7 +149,6 @@ async def show_version_info(callback: types.CallbackQuery, db_user: User, db: As
|
||||
newer_releases = version_info['newer_releases']
|
||||
has_updates = version_info['has_updates']
|
||||
last_check = version_info['last_check']
|
||||
version_info['repo_url']
|
||||
|
||||
current_info = '📦 <b>ТЕКУЩАЯ ВЕРСИЯ</b>\n\n'
|
||||
|
||||
@@ -198,6 +204,9 @@ async def show_version_info(callback: types.CallbackQuery, db_user: User, db: As
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
if 'message is not modified' in str(e).lower():
|
||||
logger.debug('📝 Сообщение не изменено в show_version_info')
|
||||
return
|
||||
logger.error(f'Ошибка получения информации о версиях: {e}')
|
||||
await callback.message.edit_text(
|
||||
f'❌ <b>ОШИБКА ЗАГРУЗКИ</b>\n\n'
|
||||
|
||||
@@ -901,16 +901,6 @@ async def _render_user_subscription_overview(callback: types.CallbackQuery, db:
|
||||
],
|
||||
]
|
||||
|
||||
if settings.is_modem_enabled():
|
||||
modem_status = '✅' if getattr(subscription, 'modem_enabled', False) else '❌'
|
||||
keyboard.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=f'📡 Модем ({modem_status})', callback_data=f'admin_user_modem_{user_id}'
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
# Кнопки тарифов в режиме тарифов
|
||||
if settings.is_tariffs_mode():
|
||||
keyboard.append(
|
||||
@@ -3638,65 +3628,6 @@ async def set_user_devices_button(callback: types.CallbackQuery, db_user: User,
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_user_modem(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
"""Переключение модема для пользователя в админке."""
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
await callback.answer('❌ Пользователь не найден', show_alert=True)
|
||||
return
|
||||
|
||||
subscription = user.subscription
|
||||
if not subscription:
|
||||
await callback.answer('❌ У пользователя нет подписки', show_alert=True)
|
||||
return
|
||||
|
||||
modem_enabled = getattr(subscription, 'modem_enabled', False) or False
|
||||
|
||||
if modem_enabled:
|
||||
# Отключаем модем
|
||||
subscription.modem_enabled = False
|
||||
if subscription.device_limit and subscription.device_limit > 1:
|
||||
subscription.device_limit = subscription.device_limit - 1
|
||||
action_text = 'отключен'
|
||||
else:
|
||||
# Включаем модем
|
||||
subscription.modem_enabled = True
|
||||
subscription.device_limit = (subscription.device_limit or 1) + 1
|
||||
action_text = 'подключен'
|
||||
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
# Обновляем в RemnaWave
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка обновления RemnaWave при переключении модема: {e}')
|
||||
|
||||
await db.refresh(subscription)
|
||||
|
||||
modem_status = '✅ Подключен' if subscription.modem_enabled else '❌ Отключен'
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'📡 <b>Модем {action_text}</b>\n\nСтатус модема: {modem_status}\nЛимит устройств: {subscription.device_limit}',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='📱 Подписка и настройки', callback_data=f'admin_user_subscription_{user_id}'
|
||||
)
|
||||
]
|
||||
]
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
|
||||
logger.info(f'Админ {db_user.telegram_id} {action_text} модем для пользователя {user_id}')
|
||||
await callback.answer()
|
||||
|
||||
@@ -5578,8 +5509,6 @@ def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(set_user_devices_button, F.data.startswith('admin_user_devices_set_'))
|
||||
|
||||
dp.callback_query.register(toggle_user_modem, F.data.startswith('admin_user_modem_'))
|
||||
|
||||
# Смена тарифа пользователя
|
||||
dp.callback_query.register(show_admin_tariff_change, F.data.startswith('admin_sub_change_tariff_'))
|
||||
|
||||
|
||||
@@ -70,24 +70,15 @@ async def start_simple_subscription_purchase(
|
||||
# (независимо от того, включён ли выбор устройств)
|
||||
if current_subscription:
|
||||
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)
|
||||
|
||||
# Проверяем, включён ли модем у текущей подписки
|
||||
modem_enabled = False
|
||||
if current_subscription:
|
||||
modem_enabled = getattr(current_subscription, 'modem_enabled', False)
|
||||
|
||||
# Подготовим параметры простой подписки
|
||||
subscription_params = {
|
||||
'period_days': settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS,
|
||||
'device_limit': device_limit,
|
||||
'traffic_limit_gb': settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB,
|
||||
'squad_uuid': settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID,
|
||||
'modem_enabled': modem_enabled,
|
||||
}
|
||||
|
||||
# Сохраняем параметры в состояние
|
||||
@@ -113,13 +104,12 @@ async def start_simple_subscription_purchase(
|
||||
user_balance_kopeks = getattr(db_user, 'balance_kopeks', 0)
|
||||
|
||||
logger.warning(
|
||||
'SIMPLE_SUBSCRIPTION_DEBUG_START | user=%s | period=%s | base=%s | traffic=%s | devices=%s | modem=%s | servers=%s | discount=%s | total=%s | squads=%s',
|
||||
'SIMPLE_SUBSCRIPTION_DEBUG_START | user=%s | period=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total=%s | squads=%s',
|
||||
db_user.id,
|
||||
period_days,
|
||||
price_breakdown.get('base_price', 0),
|
||||
price_breakdown.get('traffic_price', 0),
|
||||
price_breakdown.get('devices_price', 0),
|
||||
price_breakdown.get('modem_price', 0),
|
||||
price_breakdown.get('servers_price', 0),
|
||||
price_breakdown.get('total_discount', 0),
|
||||
price_kopeks,
|
||||
|
||||
+34
-13
@@ -931,14 +931,11 @@ async def process_privacy_policy_accept(callback: types.CallbackQuery, state: FS
|
||||
await callback.message.edit_text(
|
||||
privacy_policy_required_text, reply_markup=get_privacy_policy_keyboard(language)
|
||||
)
|
||||
except TelegramBadRequest as e:
|
||||
if 'message is not modified' not in str(e):
|
||||
logger.warning(f'Ошибка при показе сообщения об отклонении политики: {e}')
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при показе сообщения об отклонении политики конфиденциальности: {e}')
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
privacy_policy_required_text, reply_markup=get_privacy_policy_keyboard(language)
|
||||
)
|
||||
except:
|
||||
pass
|
||||
logger.warning(f'Ошибка при показе сообщения об отклонении политики: {e}')
|
||||
|
||||
logger.info(f'✅ Политика конфиденциальности обработана для пользователя {callback.from_user.id}')
|
||||
|
||||
@@ -1228,6 +1225,20 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
|
||||
)
|
||||
logger.info(f'✅ Приветственное сообщение отправлено пользователю {user.telegram_id}')
|
||||
await _send_pinned_message(callback.bot, db, user)
|
||||
except TelegramBadRequest as e:
|
||||
if 'parse entities' in str(e).lower() or "can't parse" in str(e).lower():
|
||||
logger.warning(f'HTML parse error в приветственном сообщении, повтор без parse_mode: {e}')
|
||||
try:
|
||||
await callback.message.answer(
|
||||
offer_text,
|
||||
reply_markup=get_post_registration_keyboard(user.language),
|
||||
parse_mode=None,
|
||||
)
|
||||
await _send_pinned_message(callback.bot, db, user)
|
||||
except Exception as fallback_err:
|
||||
logger.error(f'Ошибка при повторной отправке приветственного сообщения: {fallback_err}')
|
||||
else:
|
||||
logger.error(f'Ошибка при отправке приветственного сообщения: {e}')
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при отправке приветственного сообщения: {e}')
|
||||
else:
|
||||
@@ -1507,6 +1518,20 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
|
||||
)
|
||||
logger.info(f'✅ Приветственное сообщение отправлено пользователю {user.telegram_id}')
|
||||
await _send_pinned_message(message.bot, db, user)
|
||||
except TelegramBadRequest as e:
|
||||
if 'parse entities' in str(e).lower() or "can't parse" in str(e).lower():
|
||||
logger.warning(f'HTML parse error в приветственном сообщении, повтор без parse_mode: {e}')
|
||||
try:
|
||||
await message.answer(
|
||||
offer_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=None,
|
||||
)
|
||||
await _send_pinned_message(message.bot, db, user)
|
||||
except Exception as fallback_err:
|
||||
logger.error(f'Ошибка при повторной отправке приветственного сообщения: {fallback_err}')
|
||||
else:
|
||||
logger.error(f'Ошибка при отправке приветственного сообщения: {e}')
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при отправке приветственного сообщения: {e}')
|
||||
else:
|
||||
@@ -1735,6 +1760,8 @@ async def get_main_menu_text_simple(user_name, texts, db: AsyncSession):
|
||||
async def required_sub_channel_check(
|
||||
query: types.CallbackQuery, bot: Bot, state: FSMContext, db: AsyncSession, db_user=None
|
||||
):
|
||||
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
|
||||
|
||||
language = DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
|
||||
@@ -1880,8 +1907,6 @@ async def required_sub_channel_check(
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
|
||||
|
||||
is_admin = settings.is_admin(user.telegram_id)
|
||||
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(user.telegram_id)
|
||||
|
||||
@@ -1974,8 +1999,6 @@ async def required_sub_channel_check(
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
|
||||
|
||||
is_admin = settings.is_admin(user.telegram_id)
|
||||
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(user.telegram_id)
|
||||
|
||||
@@ -2028,8 +2051,6 @@ async def required_sub_channel_check(
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||
else:
|
||||
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
|
||||
|
||||
rules_text = await get_rules(language)
|
||||
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
|
||||
@@ -64,13 +64,6 @@ from .links import (
|
||||
handle_connect_subscription,
|
||||
handle_open_subscription_link,
|
||||
)
|
||||
from .modem import (
|
||||
handle_modem_confirm,
|
||||
handle_modem_disable,
|
||||
handle_modem_enable,
|
||||
handle_modem_menu,
|
||||
register_modem_handlers,
|
||||
)
|
||||
from .notifications import (
|
||||
send_extension_notification,
|
||||
send_purchase_notification,
|
||||
@@ -172,10 +165,6 @@ __all__ = [
|
||||
'handle_happ_download_platform_choice',
|
||||
'handle_happ_download_request',
|
||||
'handle_manage_country',
|
||||
'handle_modem_confirm',
|
||||
'handle_modem_disable',
|
||||
'handle_modem_enable',
|
||||
'handle_modem_menu',
|
||||
'handle_no_traffic_packages',
|
||||
'handle_open_subscription_link',
|
||||
'handle_promo_offer_close',
|
||||
@@ -190,7 +179,6 @@ __all__ = [
|
||||
'load_app_config',
|
||||
'refresh_traffic_config',
|
||||
'register_handlers',
|
||||
'register_modem_handlers',
|
||||
'resume_subscription_checkout',
|
||||
'return_to_saved_cart',
|
||||
'save_cart_and_redirect_to_topup',
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
"""
|
||||
Хендлеры для управления модемом в подписке.
|
||||
|
||||
Модем - это дополнительное устройство, которое можно подключить к подписке
|
||||
за отдельную плату. При подключении увеличивается лимит устройств.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from aiogram import Dispatcher, F, types
|
||||
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, get_insufficient_balance_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.modem_service import (
|
||||
ModemError,
|
||||
get_modem_service,
|
||||
)
|
||||
from app.utils.decorators import error_handler, modem_available
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_modem_keyboard(language: str, modem_enabled: bool):
|
||||
"""Клавиатура управления модемом."""
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
if modem_enabled:
|
||||
keyboard.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t('MODEM_DISABLE_BUTTON', 'Отключить модем'), callback_data='modem_disable'
|
||||
)
|
||||
]
|
||||
)
|
||||
else:
|
||||
keyboard.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t('MODEM_ENABLE_BUTTON', 'Подключить модем'), callback_data='modem_enable'
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='subscription_settings')])
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_modem_confirm_keyboard(language: str):
|
||||
"""Клавиатура подтверждения подключения модема."""
|
||||
texts = get_texts(language)
|
||||
return types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t('MODEM_CONFIRM_BUTTON', 'Подтвердить подключение'), callback_data='modem_confirm'
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.CANCEL, callback_data='subscription_modem')],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
@modem_available()
|
||||
async def handle_modem_menu(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
"""Показывает меню управления модемом."""
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
service = get_modem_service()
|
||||
|
||||
modem_enabled = service.get_modem_enabled(subscription)
|
||||
modem_price = settings.get_modem_price_per_month()
|
||||
|
||||
if modem_enabled:
|
||||
status_text = texts.t('MODEM_STATUS_ENABLED', 'Подключен')
|
||||
info_text = texts.t(
|
||||
'MODEM_INFO_ENABLED',
|
||||
(
|
||||
'<b>Модем</b>\n\n'
|
||||
'Статус: {status}\n\n'
|
||||
'Модем подключен к вашей подписке.\n'
|
||||
'Ежемесячная плата: {price}\n\n'
|
||||
'При отключении модема возврат средств не производится.'
|
||||
),
|
||||
).format(
|
||||
status=status_text,
|
||||
price=texts.format_price(modem_price),
|
||||
)
|
||||
else:
|
||||
status_text = texts.t('MODEM_STATUS_DISABLED', 'Не подключен')
|
||||
info_text = texts.t(
|
||||
'MODEM_INFO_DISABLED',
|
||||
(
|
||||
'<b>Модем</b>\n\n'
|
||||
'Статус: {status}\n\n'
|
||||
'Подключите модем к вашей подписке.\n'
|
||||
'Ежемесячная плата: {price}\n\n'
|
||||
'При подключении модема будет добавлено дополнительное устройство.'
|
||||
),
|
||||
).format(
|
||||
status=status_text,
|
||||
price=texts.format_price(modem_price),
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
info_text, reply_markup=get_modem_keyboard(db_user.language, modem_enabled), parse_mode='HTML'
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
@modem_available(for_enable=True)
|
||||
async def handle_modem_enable(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
"""Обработчик подключения модема - показывает информацию о цене."""
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
service = get_modem_service()
|
||||
|
||||
price_info = service.calculate_price(subscription)
|
||||
modem_price_per_month = settings.get_modem_price_per_month()
|
||||
|
||||
has_funds, missing_kopeks = service.check_balance(db_user, price_info.final_price)
|
||||
|
||||
if not has_funds:
|
||||
if price_info.has_discount:
|
||||
required_text = (
|
||||
f'{texts.format_price(price_info.final_price)} '
|
||||
f'(за {price_info.charged_months} мес, скидка {price_info.discount_percent}%)'
|
||||
)
|
||||
else:
|
||||
required_text = f'{texts.format_price(price_info.final_price)} (за {price_info.charged_months} мес)'
|
||||
|
||||
message_text = texts.t(
|
||||
'MODEM_INSUFFICIENT_FUNDS',
|
||||
(
|
||||
'<b>Недостаточно средств</b>\n\n'
|
||||
'Стоимость подключения модема: {required}\n'
|
||||
'На балансе: {balance}\n'
|
||||
'Не хватает: {missing}\n\n'
|
||||
'Выберите способ пополнения.'
|
||||
),
|
||||
).format(
|
||||
required=required_text,
|
||||
balance=texts.format_price(db_user.balance_kopeks),
|
||||
missing=texts.format_price(missing_kopeks),
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=get_insufficient_balance_keyboard(
|
||||
db_user.language,
|
||||
amount_kopeks=missing_kopeks,
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
warning_level = service.get_period_warning_level(price_info.remaining_days)
|
||||
|
||||
if warning_level == 'critical':
|
||||
warning_text = texts.t(
|
||||
'MODEM_SHORT_PERIOD_WARNING',
|
||||
'\n<b>Внимание!</b> До окончания подписки осталось всего <b>{days} дн.</b>\n'
|
||||
'После продления подписки модем нужно будет оплатить заново!',
|
||||
).format(days=price_info.remaining_days)
|
||||
elif warning_level == 'info':
|
||||
warning_text = texts.t(
|
||||
'MODEM_PERIOD_NOTE',
|
||||
'\nДо окончания подписки: <b>{days} дн.</b>\nПосле продления модем нужно будет оплатить заново.',
|
||||
).format(days=price_info.remaining_days)
|
||||
else:
|
||||
warning_text = ''
|
||||
|
||||
if price_info.has_discount:
|
||||
price_text = texts.t(
|
||||
'MODEM_PRICE_WITH_DISCOUNT',
|
||||
'Стоимость: <s>{base_price}</s> <b>{final_price}</b> (за {months} мес)\n'
|
||||
'Скидка {discount}%: -{discount_amount}',
|
||||
).format(
|
||||
base_price=texts.format_price(price_info.base_price),
|
||||
final_price=texts.format_price(price_info.final_price),
|
||||
months=price_info.charged_months,
|
||||
discount=price_info.discount_percent,
|
||||
discount_amount=texts.format_price(price_info.discount_amount),
|
||||
)
|
||||
else:
|
||||
price_text = texts.t('MODEM_PRICE_NO_DISCOUNT', 'Стоимость: {price} (за {months} мес)').format(
|
||||
price=texts.format_price(price_info.final_price),
|
||||
months=price_info.charged_months,
|
||||
)
|
||||
|
||||
confirm_text = texts.t(
|
||||
'MODEM_CONFIRM_ENABLE_BASE',
|
||||
(
|
||||
'<b>Подтверждение подключения модема</b>\n\n'
|
||||
'{price_text}\n\n'
|
||||
'При подключении модема:\n'
|
||||
'К подписке добавится дополнительное устройство\n'
|
||||
'Ежемесячная плата увеличится на {monthly_price}\n\n'
|
||||
'Подтвердить подключение?'
|
||||
),
|
||||
).format(
|
||||
price_text=price_text,
|
||||
monthly_price=texts.format_price(modem_price_per_month),
|
||||
)
|
||||
|
||||
end_date_str = price_info.end_date.strftime('%d.%m.%Y')
|
||||
period_info = texts.t('MODEM_PERIOD_INFO', '\nМодем действует до: <b>{end_date}</b> ({days} дн.)').format(
|
||||
end_date=end_date_str, days=price_info.remaining_days
|
||||
)
|
||||
|
||||
confirm_text += period_info + warning_text
|
||||
|
||||
await callback.message.edit_text(
|
||||
confirm_text, reply_markup=get_modem_confirm_keyboard(db_user.language), parse_mode='HTML'
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
@modem_available(for_enable=True)
|
||||
async def handle_modem_confirm(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
"""Подтверждение и активация модема."""
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
service = get_modem_service()
|
||||
|
||||
result = await service.enable_modem(db, db_user, subscription)
|
||||
|
||||
if not result.success:
|
||||
error_messages = {
|
||||
ModemError.INSUFFICIENT_FUNDS: texts.t('MODEM_INSUFFICIENT_FUNDS_SHORT', 'Недостаточно средств на балансе'),
|
||||
ModemError.CHARGE_ERROR: texts.t('PAYMENT_CHARGE_ERROR', 'Ошибка списания средств'),
|
||||
ModemError.UPDATE_ERROR: texts.ERROR,
|
||||
}
|
||||
|
||||
error_text = error_messages.get(result.error, texts.ERROR)
|
||||
|
||||
if result.error == ModemError.INSUFFICIENT_FUNDS:
|
||||
await callback.message.edit_text(
|
||||
error_text, reply_markup=get_back_keyboard(db_user.language, 'modem_enable'), parse_mode='HTML'
|
||||
)
|
||||
else:
|
||||
await callback.answer(error_text, show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_update_notification(
|
||||
db, db_user, subscription, 'modem', False, True, result.charged_amount
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка отправки уведомления о подключении модема: {e}')
|
||||
|
||||
success_text = texts.t(
|
||||
'MODEM_ENABLED_SUCCESS',
|
||||
('<b>Модем успешно подключен!</b>\n\nМодем активирован\nДобавлено устройство для модема\n'),
|
||||
)
|
||||
if result.charged_amount > 0:
|
||||
success_text += texts.t(
|
||||
'MODEM_CHARGED',
|
||||
'Списано: {amount}',
|
||||
).format(amount=texts.format_price(result.charged_amount))
|
||||
|
||||
await callback.message.edit_text(
|
||||
success_text, reply_markup=get_back_keyboard(db_user.language, 'subscription_settings'), parse_mode='HTML'
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
@modem_available(for_disable=True)
|
||||
async def handle_modem_disable(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
"""Отключение модема."""
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
service = get_modem_service()
|
||||
|
||||
result = await service.disable_modem(db, db_user, subscription)
|
||||
|
||||
if not result.success:
|
||||
await callback.answer(texts.ERROR, show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_update_notification(
|
||||
db, db_user, subscription, 'modem', True, False, 0
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка отправки уведомления об отключении модема: {e}')
|
||||
|
||||
success_text = texts.t(
|
||||
'MODEM_DISABLED_SUCCESS',
|
||||
('<b>Модем отключен</b>\n\nМодем деактивирован\nВозврат средств не производится'),
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
success_text, reply_markup=get_back_keyboard(db_user.language, 'subscription_settings'), parse_mode='HTML'
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_modem_handlers(dp: Dispatcher):
|
||||
"""Регистрация обработчиков модема."""
|
||||
dp.callback_query.register(handle_modem_menu, F.data == 'subscription_modem')
|
||||
|
||||
dp.callback_query.register(handle_modem_enable, F.data == 'modem_enable')
|
||||
|
||||
dp.callback_query.register(handle_modem_confirm, F.data == 'modem_confirm')
|
||||
|
||||
dp.callback_query.register(handle_modem_disable, F.data == 'modem_disable')
|
||||
@@ -404,15 +404,16 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess
|
||||
status_text = '⌛ Истекла'
|
||||
type_text = 'Платная подписка'
|
||||
|
||||
if subscription.traffic_limit_gb == 0:
|
||||
traffic_limit = subscription.traffic_limit_gb or 0
|
||||
if traffic_limit == 0:
|
||||
if settings.is_traffic_fixed():
|
||||
traffic_text = '∞ Безлимитный'
|
||||
else:
|
||||
traffic_text = '∞ Безлимитный'
|
||||
elif settings.is_traffic_fixed():
|
||||
traffic_text = f'{subscription.traffic_limit_gb} ГБ'
|
||||
traffic_text = f'{traffic_limit} ГБ'
|
||||
else:
|
||||
traffic_text = f'{subscription.traffic_limit_gb} ГБ'
|
||||
traffic_text = f'{traffic_limit} ГБ'
|
||||
|
||||
subscription_cost = await get_subscription_cost(subscription, db)
|
||||
|
||||
@@ -444,7 +445,7 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess
|
||||
info_text += f'\n💰 <b>Стоимость подписки в месяц:</b> {texts.format_price(subscription_cost)}'
|
||||
|
||||
# Отображаем докупленный трафик
|
||||
if subscription.traffic_limit_gb > 0: # Только для лимитированных тарифов
|
||||
if (subscription.traffic_limit_gb or 0) > 0: # Только для лимитированных тарифов
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select as sql_select
|
||||
|
||||
@@ -426,14 +426,7 @@ async def show_subscription_info(callback: types.CallbackQuery, db_user: User, d
|
||||
'',
|
||||
)
|
||||
|
||||
# Формируем отображение лимита устройств с учётом модема
|
||||
modem_enabled = getattr(subscription, 'modem_enabled', False) or False
|
||||
if modem_enabled and settings.is_modem_enabled():
|
||||
# Показываем лимит без модема + модем
|
||||
visible_device_limit = (subscription.device_limit or 1) - 1
|
||||
device_limit_display = f'{visible_device_limit} + модем'
|
||||
else:
|
||||
device_limit_display = str(subscription.device_limit)
|
||||
device_limit_display = str(subscription.device_limit)
|
||||
|
||||
message = message_template.format(
|
||||
full_name=db_user.full_name,
|
||||
@@ -1603,11 +1596,6 @@ async def handle_extend_subscription(callback: types.CallbackQuery, db_user: Use
|
||||
else:
|
||||
device_limit = forced_limit
|
||||
|
||||
# Модем добавляет +1 к device_limit, но оплачивается отдельно,
|
||||
# поэтому не должен учитываться как платное устройство при продлении
|
||||
if getattr(subscription, 'modem_enabled', False):
|
||||
device_limit = max(1, device_limit - 1)
|
||||
|
||||
additional_devices = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_total_base = devices_price_per_month * months_in_period
|
||||
@@ -1814,11 +1802,6 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us
|
||||
else:
|
||||
device_limit = forced_limit
|
||||
|
||||
# Модем добавляет +1 к device_limit, но оплачивается отдельно,
|
||||
# поэтому не должен учитываться как платное устройство при продлении
|
||||
if getattr(subscription, 'modem_enabled', False):
|
||||
device_limit = max(1, device_limit - 1)
|
||||
|
||||
additional_devices = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = db_user.get_promo_discount(
|
||||
@@ -2420,26 +2403,31 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_
|
||||
promo_offer_discount_percent = 0
|
||||
|
||||
# Валидация: проверяем что cached_total_price соответствует ожидаемой финальной цене
|
||||
# Допускаем небольшое расхождение из-за округления (до 5%)
|
||||
price_difference = abs(final_price - cached_total_price)
|
||||
max_allowed_difference = max(500, int(final_price * 0.05)) # 5% или минимум 5₽
|
||||
|
||||
if price_difference > max_allowed_difference:
|
||||
# Слишком большое расхождение - блокируем покупку
|
||||
logger.error(
|
||||
f'Критическое расхождение цены для пользователя {db_user.telegram_id}: '
|
||||
f'кэш={cached_total_price / 100}₽, пересчет={final_price / 100}₽, '
|
||||
f'разница={price_difference / 100}₽ (>{max_allowed_difference / 100}₽). '
|
||||
f'Покупка заблокирована.'
|
||||
)
|
||||
await callback.answer('Цена изменилась. Пожалуйста, начните оформление заново.', show_alert=True)
|
||||
return
|
||||
if price_difference > 100: # допуск 1₽
|
||||
# Небольшое расхождение - логируем предупреждение но продолжаем
|
||||
logger.warning(
|
||||
f'Расхождение цены для пользователя {db_user.telegram_id}: '
|
||||
# Блокируем только если цена ВЫРОСЛА (пользователь переплатит).
|
||||
# Если цена снизилась (промо-скидка активировалась) — разрешаем покупку по новой цене.
|
||||
price_difference = final_price - cached_total_price
|
||||
if price_difference > 0:
|
||||
max_allowed_increase = max(500, int(final_price * 0.05)) # 5% или минимум 5₽
|
||||
if price_difference > max_allowed_increase:
|
||||
logger.error(
|
||||
f'Цена выросла для пользователя {db_user.telegram_id}: '
|
||||
f'кэш={cached_total_price / 100}₽, пересчет={final_price / 100}₽, '
|
||||
f'разница=+{price_difference / 100}₽ (>{max_allowed_increase / 100}₽). '
|
||||
f'Покупка заблокирована.'
|
||||
)
|
||||
await callback.answer('Цена изменилась. Пожалуйста, начните оформление заново.', show_alert=True)
|
||||
return
|
||||
if price_difference > 100: # допуск 1₽
|
||||
logger.warning(
|
||||
f'Небольшой рост цены для пользователя {db_user.telegram_id}: '
|
||||
f'кэш={cached_total_price / 100}₽, пересчет={final_price / 100}₽. '
|
||||
f'Используем пересчитанную цену.'
|
||||
)
|
||||
elif price_difference < -100: # цена снизилась более чем на 1₽
|
||||
logger.info(
|
||||
f'Цена снизилась для пользователя {db_user.telegram_id}: '
|
||||
f'кэш={cached_total_price / 100}₽, пересчет={final_price / 100}₽. '
|
||||
f'Используем пересчитанную цену.'
|
||||
f'Применяем новую цену.'
|
||||
)
|
||||
|
||||
# Используем пересчитанную цену
|
||||
@@ -3060,13 +3048,7 @@ async def handle_subscription_settings(callback: types.CallbackQuery, db_user: U
|
||||
'',
|
||||
)
|
||||
|
||||
# Формируем отображение лимита устройств с учётом модема
|
||||
modem_enabled = getattr(subscription, 'modem_enabled', False) or False
|
||||
if modem_enabled and settings.is_modem_enabled():
|
||||
visible_device_limit = (subscription.device_limit or 1) - 1
|
||||
devices_limit_display = f'{visible_device_limit} + модем'
|
||||
else:
|
||||
devices_limit_display = str(subscription.device_limit)
|
||||
devices_limit_display = str(subscription.device_limit)
|
||||
|
||||
settings_text = settings_template.format(
|
||||
countries_count=len(subscription.connected_squads),
|
||||
@@ -4113,11 +4095,6 @@ def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(show_device_connection_help, F.data == 'device_connection_help')
|
||||
|
||||
# Регистрируем обработчики модема
|
||||
from .modem import register_modem_handlers
|
||||
|
||||
register_modem_handlers(dp)
|
||||
|
||||
# Регистрируем обработчики покупки по тарифам
|
||||
from .tariff_purchase import register_tariff_purchase_handlers
|
||||
|
||||
@@ -4152,10 +4129,6 @@ async def handle_simple_subscription_purchase(
|
||||
if current_subscription and current_subscription.is_active:
|
||||
# При продлении используем текущие устройства подписки, а не дефолтные
|
||||
extend_device_limit = current_subscription.device_limit or simple_device_limit
|
||||
# Модем добавляет +1 к device_limit, но оплачивается отдельно
|
||||
modem_enabled = getattr(current_subscription, 'modem_enabled', False)
|
||||
if modem_enabled:
|
||||
extend_device_limit = max(1, extend_device_limit - 1)
|
||||
# Используем максимум из текущего и дефолтного
|
||||
extend_device_limit = max(simple_device_limit, extend_device_limit)
|
||||
|
||||
@@ -4169,7 +4142,6 @@ async def handle_simple_subscription_purchase(
|
||||
device_limit=extend_device_limit,
|
||||
traffic_limit_gb=settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB,
|
||||
squad_uuid=settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID,
|
||||
modem_enabled=modem_enabled,
|
||||
)
|
||||
return
|
||||
|
||||
@@ -4289,7 +4261,6 @@ async def _extend_existing_subscription(
|
||||
device_limit: int,
|
||||
traffic_limit_gb: int,
|
||||
squad_uuid: str,
|
||||
modem_enabled: bool = False,
|
||||
):
|
||||
"""Продлевает существующую подписку."""
|
||||
from datetime import datetime, timedelta
|
||||
@@ -4307,7 +4278,6 @@ async def _extend_existing_subscription(
|
||||
'device_limit': device_limit,
|
||||
'traffic_limit_gb': traffic_limit_gb,
|
||||
'squad_uuid': squad_uuid,
|
||||
'modem_enabled': modem_enabled,
|
||||
}
|
||||
price_kopeks, price_breakdown = await _calculate_simple_subscription_price(
|
||||
db,
|
||||
@@ -4316,17 +4286,15 @@ async def _extend_existing_subscription(
|
||||
resolved_squad_uuid=squad_uuid,
|
||||
)
|
||||
logger.warning(
|
||||
'SIMPLE_SUBSCRIPTION_EXTEND_PRICE | user=%s | total=%s | base=%s | traffic=%s | devices=%s | modem=%s | servers=%s | discount=%s | device_limit=%s | modem_enabled=%s',
|
||||
'SIMPLE_SUBSCRIPTION_EXTEND_PRICE | user=%s | total=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | device_limit=%s',
|
||||
db_user.id,
|
||||
price_kopeks,
|
||||
price_breakdown.get('base_price', 0),
|
||||
price_breakdown.get('traffic_price', 0),
|
||||
price_breakdown.get('devices_price', 0),
|
||||
price_breakdown.get('modem_price', 0),
|
||||
price_breakdown.get('servers_price', 0),
|
||||
price_breakdown.get('total_discount', 0),
|
||||
device_limit,
|
||||
modem_enabled,
|
||||
)
|
||||
|
||||
# Проверяем баланс пользователя
|
||||
|
||||
@@ -2620,11 +2620,6 @@ def get_updated_subscription_settings_keyboard(
|
||||
]
|
||||
)
|
||||
|
||||
if settings.is_modem_enabled() and not has_tariff:
|
||||
keyboard.append(
|
||||
[InlineKeyboardButton(text=texts.t('MODEM_BUTTON', '📡 Модем'), callback_data='subscription_modem')]
|
||||
)
|
||||
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.exceptions import TelegramForbiddenError
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, Message, TelegramObject, User as TgUser
|
||||
from sqlalchemy.exc import InterfaceError, OperationalError
|
||||
@@ -219,6 +220,10 @@ class AuthMiddleware(BaseMiddleware):
|
||||
if hasattr(event, 'data'):
|
||||
logger.error(f'Callback data: {event.data}')
|
||||
raise
|
||||
except TelegramForbiddenError:
|
||||
# User blocked the bot — normal, not an error
|
||||
logger.debug('AuthMiddleware: bot blocked by user, skipping')
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка в AuthMiddleware: {e}')
|
||||
logger.error(f'Event type: {type(event)}')
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Any
|
||||
import redis.asyncio as aioredis
|
||||
from aiogram import BaseMiddleware, Bot, types
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramNetworkError
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, Message, TelegramObject, Update
|
||||
|
||||
@@ -197,6 +197,9 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
logger.error(f'❌ Ошибка запроса к каналу {channel_id}: {e}')
|
||||
await self._capture_start_payload(state, event, bot)
|
||||
return await self._deny_message(event, bot, channel_link, channel_id)
|
||||
except TelegramNetworkError as e:
|
||||
logger.warning(f'⚠️ Таймаут при проверке подписки на канал: {e}')
|
||||
return await handler(event, data)
|
||||
except Exception as e:
|
||||
logger.error(f'❌ Неожиданная ошибка при проверке подписки: {e}')
|
||||
return await handler(event, data)
|
||||
|
||||
@@ -1534,7 +1534,6 @@ class AdminNotificationService:
|
||||
'traffic': '📊 ДОКУПКА ТРАФИКА',
|
||||
'devices': '📱 ДОКУПКА УСТРОЙСТВ',
|
||||
'servers': '🌐 СМЕНА СЕРВЕРОВ',
|
||||
'modem': '📡 МОДЕМ',
|
||||
}
|
||||
title = update_titles.get(update_type, '⚙️ ИЗМЕНЕНИЕ ПОДПИСКИ')
|
||||
|
||||
@@ -1570,10 +1569,6 @@ class AdminNotificationService:
|
||||
message_lines.append(f'🔄 {old_formatted} → {new_formatted}')
|
||||
elif update_type == 'devices':
|
||||
message_lines.append(f'🔄 {old_value} → {new_value} устр.')
|
||||
elif update_type == 'modem':
|
||||
old_state = '✅ Вкл' if old_value else '❌ Выкл'
|
||||
new_state = '✅ Вкл' if new_value else '❌ Выкл'
|
||||
message_lines.append(f'🔄 {old_state} → {new_state}')
|
||||
else:
|
||||
message_lines.append(f'🔄 {old_value} → {new_value}')
|
||||
|
||||
@@ -1638,8 +1633,6 @@ class AdminNotificationService:
|
||||
if isinstance(value, list):
|
||||
return f'{len(value)} серверов'
|
||||
return str(value)
|
||||
if update_type == 'modem':
|
||||
return '✅ Включён' if value else '❌ Выключен'
|
||||
return str(value)
|
||||
|
||||
async def send_bulk_ban_notification(
|
||||
|
||||
+217
-58
@@ -7,7 +7,7 @@ import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import date as dt_date, datetime, time as dt_time, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -24,14 +24,41 @@ from app.database.models import (
|
||||
AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration,
|
||||
BroadcastHistory,
|
||||
ButtonClickLog,
|
||||
CloudPaymentsPayment,
|
||||
ContestAttempt,
|
||||
ContestRound,
|
||||
ContestTemplate,
|
||||
CryptoBotPayment,
|
||||
DiscountOffer,
|
||||
FaqPage,
|
||||
FaqSetting,
|
||||
FreekassaPayment,
|
||||
HeleketPayment,
|
||||
KassaAiPayment,
|
||||
MainMenuButton,
|
||||
MenuLayoutHistory,
|
||||
MonitoringLog,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
PaymentMethodConfig,
|
||||
PinnedMessage,
|
||||
PlategaPayment,
|
||||
Poll,
|
||||
PollAnswer,
|
||||
PollOption,
|
||||
PollQuestion,
|
||||
PollResponse,
|
||||
PrivacyPolicy,
|
||||
PromoCode,
|
||||
PromoCodeUse,
|
||||
PromoGroup,
|
||||
PromoOfferLog,
|
||||
PromoOfferTemplate,
|
||||
PublicOffer,
|
||||
ReferralContest,
|
||||
ReferralContestEvent,
|
||||
ReferralContestVirtualParticipant,
|
||||
ReferralEarning,
|
||||
SentNotification,
|
||||
ServerSquad,
|
||||
@@ -39,19 +66,33 @@ from app.database.models import (
|
||||
Squad,
|
||||
Subscription,
|
||||
SubscriptionConversion,
|
||||
SubscriptionEvent,
|
||||
SubscriptionServer,
|
||||
SubscriptionTemporaryAccess,
|
||||
SupportAuditLog,
|
||||
SystemSetting,
|
||||
Tariff,
|
||||
Ticket,
|
||||
TicketMessage,
|
||||
TicketNotification,
|
||||
TrafficPurchase,
|
||||
Transaction,
|
||||
User,
|
||||
UserMessage,
|
||||
UserPromoGroup,
|
||||
WataPayment,
|
||||
WebApiToken,
|
||||
Webhook,
|
||||
WebhookDelivery,
|
||||
WelcomeText,
|
||||
WheelConfig,
|
||||
WheelPrize,
|
||||
WheelSpin,
|
||||
WithdrawalRequest,
|
||||
YooKassaPayment,
|
||||
payment_method_promo_groups,
|
||||
server_squad_promo_groups,
|
||||
tariff_promo_groups,
|
||||
)
|
||||
|
||||
|
||||
@@ -122,6 +163,53 @@ class BackupService:
|
||||
TicketMessage,
|
||||
SupportAuditLog,
|
||||
WebApiToken,
|
||||
# --- Payment providers (FK: users, transactions) ---
|
||||
HeleketPayment,
|
||||
WataPayment,
|
||||
PlategaPayment,
|
||||
CloudPaymentsPayment,
|
||||
FreekassaPayment,
|
||||
KassaAiPayment,
|
||||
# --- Settings/content ---
|
||||
PaymentMethodConfig,
|
||||
PrivacyPolicy,
|
||||
PublicOffer,
|
||||
FaqSetting,
|
||||
FaqPage,
|
||||
PinnedMessage,
|
||||
MainMenuButton,
|
||||
MenuLayoutHistory,
|
||||
# --- User data (FK: users, promo_groups, subscriptions) ---
|
||||
UserPromoGroup,
|
||||
TrafficPurchase,
|
||||
SubscriptionEvent,
|
||||
SubscriptionTemporaryAccess,
|
||||
PromoOfferTemplate,
|
||||
PromoOfferLog,
|
||||
# --- Referral/contests (FK: users) ---
|
||||
WithdrawalRequest,
|
||||
ReferralContest,
|
||||
ReferralContestEvent,
|
||||
ReferralContestVirtualParticipant,
|
||||
ContestTemplate,
|
||||
ContestRound,
|
||||
ContestAttempt,
|
||||
# --- Polls (FK chain: polls -> questions -> options -> answers) ---
|
||||
Poll,
|
||||
PollQuestion,
|
||||
PollOption,
|
||||
PollResponse,
|
||||
PollAnswer,
|
||||
# --- Webhooks ---
|
||||
Webhook,
|
||||
WebhookDelivery,
|
||||
# --- Wheel (FK chain: configs -> prizes -> spins) ---
|
||||
WheelConfig,
|
||||
WheelPrize,
|
||||
WheelSpin,
|
||||
# --- Support ---
|
||||
TicketNotification,
|
||||
ButtonClickLog,
|
||||
]
|
||||
|
||||
self.backup_models_ordered = self._base_backup_models.copy()
|
||||
@@ -131,6 +219,8 @@ class BackupService:
|
||||
|
||||
self.association_tables = {
|
||||
'server_squad_promo_groups': server_squad_promo_groups,
|
||||
'tariff_promo_groups': tariff_promo_groups,
|
||||
'payment_method_promo_groups': payment_method_promo_groups,
|
||||
}
|
||||
|
||||
def _load_settings(self) -> BackupSettings:
|
||||
@@ -509,7 +599,7 @@ class BackupService:
|
||||
|
||||
if value is None:
|
||||
record_dict[column.name] = None
|
||||
elif isinstance(value, datetime):
|
||||
elif isinstance(value, (datetime, dt_date, dt_time)):
|
||||
record_dict[column.name] = value.isoformat()
|
||||
elif isinstance(value, (list, dict)):
|
||||
record_dict[column.name] = json_lib.dumps(value) if value else None
|
||||
@@ -538,7 +628,7 @@ class BackupService:
|
||||
|
||||
except Exception as exc:
|
||||
logger.error('Ошибка при экспорте данных: %s', exc)
|
||||
raise exc
|
||||
raise
|
||||
|
||||
async def _collect_files(self, staging_dir: Path, include_logs: bool) -> list[dict[str, Any]]:
|
||||
files_info: list[dict[str, Any]] = []
|
||||
@@ -623,7 +713,7 @@ class BackupService:
|
||||
|
||||
mode = 'r:gz' if backup_path.suffixes and backup_path.suffixes[-1] == '.gz' else 'r'
|
||||
with tarfile.open(backup_path, mode) as tar:
|
||||
tar.extractall(temp_path)
|
||||
tar.extractall(temp_path, filter='data')
|
||||
|
||||
metadata_path = temp_path / 'metadata.json'
|
||||
if not metadata_path.exists():
|
||||
@@ -785,20 +875,31 @@ class BackupService:
|
||||
logger.info('📁 Снимок директории data восстановлен')
|
||||
|
||||
async def _restore_files(self, files_info: list[dict[str, Any]], temp_path: Path):
|
||||
allowed_base = self.data_dir.resolve()
|
||||
|
||||
for file_info in files_info:
|
||||
relative_path = file_info.get('relative_path')
|
||||
target_path = Path(file_info.get('path', ''))
|
||||
if not relative_path or not target_path:
|
||||
continue
|
||||
|
||||
source_file = temp_path / relative_path
|
||||
target_resolved = target_path.resolve()
|
||||
if not str(target_resolved).startswith(str(allowed_base) + os.sep) and target_resolved != allowed_base:
|
||||
logger.warning('Заблокирована запись за пределами data_dir: %s', target_path)
|
||||
continue
|
||||
|
||||
source_file = (temp_path / relative_path).resolve()
|
||||
if not str(source_file).startswith(str(temp_path.resolve()) + os.sep):
|
||||
logger.warning('Path traversal в relative_path: %s', relative_path)
|
||||
continue
|
||||
|
||||
if not source_file.exists():
|
||||
logger.warning('Файл %s отсутствует в архиве', relative_path)
|
||||
continue
|
||||
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
await asyncio.to_thread(shutil.copy2, source_file, target_path)
|
||||
logger.info('📁 Файл %s восстановлен', target_path)
|
||||
target_resolved.parent.mkdir(parents=True, exist_ok=True)
|
||||
await asyncio.to_thread(shutil.copy2, source_file, target_resolved)
|
||||
logger.info('📁 Файл %s восстановлен', target_resolved)
|
||||
|
||||
async def _restore_database_payload(
|
||||
self,
|
||||
@@ -914,7 +1015,7 @@ class BackupService:
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
logger.error('Ошибка при восстановлении: %s', exc)
|
||||
raise exc
|
||||
raise
|
||||
|
||||
return restored_tables, restored_records
|
||||
|
||||
@@ -994,10 +1095,9 @@ class BackupService:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при восстановлении пользователя: {e}')
|
||||
await db.rollback()
|
||||
raise e
|
||||
raise
|
||||
|
||||
await db.commit()
|
||||
await db.flush()
|
||||
logger.info('✅ Пользователи без реферальных связей восстановлены')
|
||||
|
||||
async def _update_user_referrals(self, db: AsyncSession, backup_data: dict):
|
||||
@@ -1031,7 +1131,7 @@ class BackupService:
|
||||
logger.error(f'Ошибка при обновлении реферальной связи: {e}')
|
||||
continue
|
||||
|
||||
await db.commit()
|
||||
await db.flush()
|
||||
logger.info('✅ Реферальные связи обновлены')
|
||||
|
||||
def _process_record_data(self, record_data: dict, model, table_name: str) -> dict:
|
||||
@@ -1058,6 +1158,18 @@ class BackupService:
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f'Не удалось парсить дату {value} для поля {key}: {e}')
|
||||
processed_data[key] = datetime.utcnow()
|
||||
elif column_type_str == 'TIME' and isinstance(value, str):
|
||||
try:
|
||||
processed_data[key] = dt_time.fromisoformat(value)
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f'Не удалось парсить время {value} для поля {key}: {e}')
|
||||
processed_data[key] = dt_time(hour=12, minute=0)
|
||||
elif column_type_str == 'DATE' and isinstance(value, str):
|
||||
try:
|
||||
processed_data[key] = dt_date.fromisoformat(value)
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f'Не удалось парсить дату {value} для поля {key}: {e}')
|
||||
processed_data[key] = None
|
||||
elif ('BOOLEAN' in column_type_str or 'BOOL' in column_type_str) and isinstance(value, str):
|
||||
processed_data[key] = value.lower() in ('true', '1', 'yes', 'on')
|
||||
elif (
|
||||
@@ -1089,11 +1201,8 @@ class BackupService:
|
||||
|
||||
return processed_data
|
||||
|
||||
def _get_primary_key_column(self, model) -> str | None:
|
||||
for col in model.__table__.columns:
|
||||
if col.primary_key:
|
||||
return col.name
|
||||
return None
|
||||
def _get_primary_key_columns(self, model) -> list[str]:
|
||||
return [col.name for col in model.__table__.columns if col.primary_key]
|
||||
|
||||
async def _export_association_tables(self, db: AsyncSession) -> dict[str, list[dict[str, Any]]]:
|
||||
association_data: dict[str, list[dict[str, Any]]] = {}
|
||||
@@ -1119,63 +1228,60 @@ class BackupService:
|
||||
restored_tables = 0
|
||||
restored_records = 0
|
||||
|
||||
if 'server_squad_promo_groups' in association_data:
|
||||
restored = await self._restore_server_squad_promo_groups(
|
||||
db, association_data['server_squad_promo_groups'], clear_existing
|
||||
for table_name, table_obj in self.association_tables.items():
|
||||
if table_name not in association_data:
|
||||
continue
|
||||
col_names = [col.name for col in table_obj.columns]
|
||||
restored = await self._restore_association_table(
|
||||
db, table_obj, table_name, association_data[table_name], clear_existing, col_names
|
||||
)
|
||||
restored_tables += 1
|
||||
restored_records += restored
|
||||
|
||||
return restored_tables, restored_records
|
||||
|
||||
async def _restore_server_squad_promo_groups(
|
||||
self, db: AsyncSession, records: list[dict[str, Any]], clear_existing: bool
|
||||
async def _restore_association_table(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
table_obj,
|
||||
table_name: str,
|
||||
records: list[dict[str, Any]],
|
||||
clear_existing: bool,
|
||||
col_names: list[str],
|
||||
) -> int:
|
||||
if not records:
|
||||
return 0
|
||||
|
||||
if clear_existing:
|
||||
await db.execute(server_squad_promo_groups.delete())
|
||||
await db.execute(table_obj.delete())
|
||||
|
||||
restored = 0
|
||||
|
||||
for record in records:
|
||||
server_id = record.get('server_squad_id')
|
||||
promo_id = record.get('promo_group_id')
|
||||
values = {col: record.get(col) for col in col_names}
|
||||
|
||||
if server_id is None or promo_id is None:
|
||||
logger.warning('Пропущена некорректная запись server_squad_promo_groups: %s', record)
|
||||
if any(v is None for v in values.values()):
|
||||
logger.warning('Пропущена некорректная запись %s: %s', table_name, record)
|
||||
continue
|
||||
|
||||
try:
|
||||
first_col = col_names[0]
|
||||
exists_stmt = (
|
||||
select(server_squad_promo_groups.c.server_squad_id)
|
||||
.where(
|
||||
server_squad_promo_groups.c.server_squad_id == server_id,
|
||||
server_squad_promo_groups.c.promo_group_id == promo_id,
|
||||
)
|
||||
select(table_obj.c[first_col])
|
||||
.where(*[table_obj.c[col] == values[col] for col in col_names])
|
||||
.limit(1)
|
||||
)
|
||||
existing = await db.execute(exists_stmt)
|
||||
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
logger.debug(
|
||||
'Запись server_squad_promo_groups (%s, %s) уже существует',
|
||||
server_id,
|
||||
promo_id,
|
||||
)
|
||||
logger.debug('Запись %s %s уже существует', table_name, values)
|
||||
continue
|
||||
|
||||
await db.execute(
|
||||
server_squad_promo_groups.insert().values(server_squad_id=server_id, promo_group_id=promo_id)
|
||||
)
|
||||
await db.execute(table_obj.insert().values(**values))
|
||||
restored += 1
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка при восстановлении связи server_squad_promo_groups (%s, %s): %s', server_id, promo_id, e
|
||||
)
|
||||
await db.rollback()
|
||||
raise e
|
||||
logger.error('Ошибка при восстановлении связи %s %s: %s', table_name, values, e)
|
||||
raise
|
||||
|
||||
return restored
|
||||
|
||||
@@ -1205,17 +1311,16 @@ class BackupService:
|
||||
logger.warning(f'⚠️ Тариф {tariff_id} не найден, устанавливаем tariff_id=NULL для подписки')
|
||||
processed_data['tariff_id'] = None
|
||||
|
||||
primary_key_col = self._get_primary_key_column(model)
|
||||
pk_cols = self._get_primary_key_columns(model)
|
||||
|
||||
if primary_key_col and primary_key_col in processed_data:
|
||||
existing_record = await db.execute(
|
||||
select(model).where(getattr(model, primary_key_col) == processed_data[primary_key_col])
|
||||
)
|
||||
if pk_cols and all(col in processed_data for col in pk_cols):
|
||||
where_clause = [getattr(model, col) == processed_data[col] for col in pk_cols]
|
||||
existing_record = await db.execute(select(model).where(*where_clause))
|
||||
existing = existing_record.scalar_one_or_none()
|
||||
|
||||
if existing and not clear_existing:
|
||||
if existing:
|
||||
for key, value in processed_data.items():
|
||||
if key != primary_key_col:
|
||||
if key not in pk_cols:
|
||||
setattr(existing, key, value)
|
||||
else:
|
||||
instance = model(**processed_data)
|
||||
@@ -1229,17 +1334,69 @@ class BackupService:
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка восстановления записи в {table_name}: {e}')
|
||||
logger.error(f'Проблемные данные: {record_data}')
|
||||
await db.rollback()
|
||||
raise e
|
||||
raise
|
||||
|
||||
return restored_count
|
||||
|
||||
async def _clear_database_tables(self, db: AsyncSession, backup_data: dict[str, Any] | None = None):
|
||||
tables_order = [
|
||||
# --- Association tables (no FK deps on them, safe to delete first) ---
|
||||
'server_squad_promo_groups',
|
||||
'tariff_promo_groups',
|
||||
'payment_method_promo_groups',
|
||||
# --- Polls (child -> parent order) ---
|
||||
'poll_answers',
|
||||
'poll_responses',
|
||||
'poll_options',
|
||||
'poll_questions',
|
||||
'polls',
|
||||
# --- Wheel (child -> parent) ---
|
||||
'wheel_spins',
|
||||
'wheel_prizes',
|
||||
'wheel_configs',
|
||||
# --- Contests (child -> parent) ---
|
||||
'contest_attempts',
|
||||
'contest_rounds',
|
||||
'contest_templates',
|
||||
'referral_contest_virtual_participants',
|
||||
'referral_contest_events',
|
||||
'referral_contests',
|
||||
# --- Webhooks ---
|
||||
'webhook_deliveries',
|
||||
'webhooks',
|
||||
# --- Promo offers ---
|
||||
'promo_offer_logs',
|
||||
'promo_offer_templates',
|
||||
'subscription_temporary_access',
|
||||
# --- User engagement ---
|
||||
'subscription_events',
|
||||
'traffic_purchases',
|
||||
'user_promo_groups',
|
||||
'withdrawal_requests',
|
||||
# --- Support extras ---
|
||||
'ticket_notifications',
|
||||
'button_click_logs',
|
||||
# --- Payment providers ---
|
||||
'heleket_payments',
|
||||
'wata_payments',
|
||||
'platega_payments',
|
||||
'cloudpayments_payments',
|
||||
'freekassa_payments',
|
||||
'kassa_ai_payments',
|
||||
# --- Content/config ---
|
||||
'pinned_messages',
|
||||
'main_menu_buttons',
|
||||
'menu_layout_history',
|
||||
'faq_pages',
|
||||
'faq_settings',
|
||||
'privacy_policies',
|
||||
'public_offers',
|
||||
'payment_method_configs',
|
||||
# --- Original tables (preserved order) ---
|
||||
'support_audit_logs',
|
||||
'ticket_messages',
|
||||
'tickets',
|
||||
'support_audit_logs',
|
||||
'cabinet_refresh_tokens',
|
||||
'advertising_campaign_registrations',
|
||||
'advertising_campaigns',
|
||||
'subscription_servers',
|
||||
@@ -1408,9 +1565,11 @@ class BackupService:
|
||||
|
||||
async def delete_backup(self, backup_filename: str) -> tuple[bool, str]:
|
||||
try:
|
||||
backup_path = self.backup_dir / backup_filename
|
||||
backup_path = (self.backup_dir / backup_filename).resolve()
|
||||
if not str(backup_path).startswith(str(self.backup_dir.resolve()) + os.sep):
|
||||
return False, '❌ Недопустимое имя файла бекапа'
|
||||
|
||||
if not backup_path.exists():
|
||||
if not backup_path.is_file():
|
||||
return False, f'❌ Файл бекапа не найден: {backup_filename}'
|
||||
|
||||
backup_path.unlink()
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
"""
|
||||
Сервис для управления модемом в подписке.
|
||||
|
||||
Модем - это дополнительное устройство, которое можно подключить к подписке
|
||||
за отдельную плату. При подключении увеличивается лимит устройств.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import Subscription, TransactionType, User
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.utils.pricing_utils import calculate_prorated_price
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ModemError(Enum):
|
||||
"""Типы ошибок при работе с модемом."""
|
||||
|
||||
NO_SUBSCRIPTION = 'no_subscription'
|
||||
TRIAL_SUBSCRIPTION = 'trial_subscription'
|
||||
MODEM_DISABLED = 'modem_disabled'
|
||||
ALREADY_ENABLED = 'already_enabled'
|
||||
NOT_ENABLED = 'not_enabled'
|
||||
INSUFFICIENT_FUNDS = 'insufficient_funds'
|
||||
CHARGE_ERROR = 'charge_error'
|
||||
UPDATE_ERROR = 'update_error'
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModemAvailabilityResult:
|
||||
"""Результат проверки доступности модема."""
|
||||
|
||||
available: bool
|
||||
error: ModemError | None = None
|
||||
modem_enabled: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModemPriceResult:
|
||||
"""Результат расчёта цены модема."""
|
||||
|
||||
base_price: int
|
||||
final_price: int
|
||||
discount_percent: int
|
||||
discount_amount: int
|
||||
charged_months: int
|
||||
remaining_days: int
|
||||
end_date: datetime
|
||||
|
||||
@property
|
||||
def has_discount(self) -> bool:
|
||||
return self.discount_percent > 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModemEnableResult:
|
||||
"""Результат подключения модема."""
|
||||
|
||||
success: bool
|
||||
error: ModemError | None = None
|
||||
charged_amount: int = 0
|
||||
new_device_limit: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModemDisableResult:
|
||||
"""Результат отключения модема."""
|
||||
|
||||
success: bool
|
||||
error: ModemError | None = None
|
||||
new_device_limit: int = 0
|
||||
|
||||
|
||||
# Константы для предупреждений о сроке действия
|
||||
MODEM_WARNING_DAYS_CRITICAL = 7
|
||||
MODEM_WARNING_DAYS_INFO = 30
|
||||
|
||||
|
||||
class ModemService:
|
||||
"""
|
||||
Сервис для управления модемом в подписке.
|
||||
|
||||
Инкапсулирует всю бизнес-логику:
|
||||
- Проверки доступности
|
||||
- Расчёт цен и скидок
|
||||
- Подключение/отключение модема
|
||||
- Синхронизация с RemnaWave
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._subscription_service = SubscriptionService()
|
||||
|
||||
@staticmethod
|
||||
def is_modem_feature_enabled() -> bool:
|
||||
"""Проверяет, включена ли функция модема в настройках."""
|
||||
return settings.is_modem_enabled()
|
||||
|
||||
@staticmethod
|
||||
def get_modem_enabled(subscription: Subscription | None) -> bool:
|
||||
"""Безопасно получает статус модема из подписки."""
|
||||
if subscription is None:
|
||||
return False
|
||||
return getattr(subscription, 'modem_enabled', False) or False
|
||||
|
||||
def check_availability(
|
||||
self, user: User, for_enable: bool = False, for_disable: bool = False
|
||||
) -> ModemAvailabilityResult:
|
||||
"""
|
||||
Проверяет доступность модема для пользователя.
|
||||
|
||||
Args:
|
||||
user: Пользователь
|
||||
for_enable: Проверка для подключения (модем должен быть отключен)
|
||||
for_disable: Проверка для отключения (модем должен быть включен)
|
||||
|
||||
Returns:
|
||||
ModemAvailabilityResult с результатом проверки
|
||||
"""
|
||||
subscription = user.subscription
|
||||
modem_enabled = self.get_modem_enabled(subscription)
|
||||
|
||||
if not subscription:
|
||||
return ModemAvailabilityResult(
|
||||
available=False, error=ModemError.NO_SUBSCRIPTION, modem_enabled=modem_enabled
|
||||
)
|
||||
|
||||
if subscription.is_trial:
|
||||
return ModemAvailabilityResult(
|
||||
available=False, error=ModemError.TRIAL_SUBSCRIPTION, modem_enabled=modem_enabled
|
||||
)
|
||||
|
||||
if not self.is_modem_feature_enabled():
|
||||
return ModemAvailabilityResult(
|
||||
available=False, error=ModemError.MODEM_DISABLED, modem_enabled=modem_enabled
|
||||
)
|
||||
|
||||
if for_enable and modem_enabled:
|
||||
return ModemAvailabilityResult(
|
||||
available=False, error=ModemError.ALREADY_ENABLED, modem_enabled=modem_enabled
|
||||
)
|
||||
|
||||
if for_disable and not modem_enabled:
|
||||
return ModemAvailabilityResult(available=False, error=ModemError.NOT_ENABLED, modem_enabled=modem_enabled)
|
||||
|
||||
return ModemAvailabilityResult(available=True, modem_enabled=modem_enabled)
|
||||
|
||||
def calculate_price(self, subscription: Subscription) -> ModemPriceResult:
|
||||
"""
|
||||
Рассчитывает стоимость подключения модема.
|
||||
|
||||
Использует пропорциональную цену на основе оставшегося времени подписки
|
||||
и применяет скидки в зависимости от периода.
|
||||
|
||||
Args:
|
||||
subscription: Подписка пользователя
|
||||
|
||||
Returns:
|
||||
ModemPriceResult с детализацией цены
|
||||
"""
|
||||
modem_price_per_month = settings.get_modem_price_per_month()
|
||||
|
||||
base_price, charged_months = calculate_prorated_price(
|
||||
modem_price_per_month,
|
||||
subscription.end_date,
|
||||
)
|
||||
|
||||
now = datetime.utcnow()
|
||||
remaining_days = max(0, (subscription.end_date - now).days)
|
||||
|
||||
discount_percent = settings.get_modem_period_discount(charged_months)
|
||||
if discount_percent > 0:
|
||||
discount_amount = base_price * discount_percent // 100
|
||||
final_price = base_price - discount_amount
|
||||
else:
|
||||
discount_amount = 0
|
||||
final_price = base_price
|
||||
|
||||
return ModemPriceResult(
|
||||
base_price=base_price,
|
||||
final_price=final_price,
|
||||
discount_percent=discount_percent,
|
||||
discount_amount=discount_amount,
|
||||
charged_months=charged_months,
|
||||
remaining_days=remaining_days,
|
||||
end_date=subscription.end_date,
|
||||
)
|
||||
|
||||
def check_balance(self, user: User, price: int) -> tuple[bool, int]:
|
||||
"""
|
||||
Проверяет достаточность баланса.
|
||||
|
||||
Args:
|
||||
user: Пользователь
|
||||
price: Требуемая сумма
|
||||
|
||||
Returns:
|
||||
Tuple[достаточно ли средств, недостающая сумма]
|
||||
"""
|
||||
if price <= 0:
|
||||
return True, 0
|
||||
|
||||
if user.balance_kopeks >= price:
|
||||
return True, 0
|
||||
|
||||
missing = price - user.balance_kopeks
|
||||
return False, missing
|
||||
|
||||
async def enable_modem(self, db: AsyncSession, user: User, subscription: Subscription) -> ModemEnableResult:
|
||||
"""
|
||||
Подключает модем к подписке.
|
||||
|
||||
Выполняет:
|
||||
1. Расчёт цены
|
||||
2. Проверку баланса
|
||||
3. Списание средств
|
||||
4. Создание транзакции
|
||||
5. Обновление подписки
|
||||
6. Синхронизацию с RemnaWave
|
||||
|
||||
Args:
|
||||
db: Сессия базы данных
|
||||
user: Пользователь
|
||||
subscription: Подписка
|
||||
|
||||
Returns:
|
||||
ModemEnableResult с результатом операции
|
||||
"""
|
||||
price_info = self.calculate_price(subscription)
|
||||
price = price_info.final_price
|
||||
|
||||
has_funds, _ = self.check_balance(user, price)
|
||||
if not has_funds:
|
||||
return ModemEnableResult(success=False, error=ModemError.INSUFFICIENT_FUNDS)
|
||||
|
||||
try:
|
||||
if price > 0:
|
||||
success = await subtract_user_balance(db, user, price, 'Подключение модема')
|
||||
|
||||
if not success:
|
||||
return ModemEnableResult(success=False, error=ModemError.CHARGE_ERROR)
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=price,
|
||||
description=f'Подключение модема на {price_info.charged_months} мес',
|
||||
)
|
||||
|
||||
subscription.modem_enabled = True
|
||||
subscription.device_limit = (subscription.device_limit or 1) + 1
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
await self._subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
await db.refresh(user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
user_id_display = user.telegram_id or user.email or f'#{user.id}'
|
||||
logger.info(f'Пользователь {user_id_display} подключил модем, списано: {price / 100}₽')
|
||||
|
||||
return ModemEnableResult(success=True, charged_amount=price, new_device_limit=subscription.device_limit)
|
||||
|
||||
except Exception as e:
|
||||
user_id_display = user.telegram_id or user.email or f'#{user.id}'
|
||||
logger.error(f'Ошибка подключения модема для пользователя {user_id_display}: {e}')
|
||||
await db.rollback()
|
||||
return ModemEnableResult(success=False, error=ModemError.UPDATE_ERROR)
|
||||
|
||||
async def disable_modem(self, db: AsyncSession, user: User, subscription: Subscription) -> ModemDisableResult:
|
||||
"""
|
||||
Отключает модем от подписки.
|
||||
|
||||
Возврат средств не производится.
|
||||
|
||||
Args:
|
||||
db: Сессия базы данных
|
||||
user: Пользователь
|
||||
subscription: Подписка
|
||||
|
||||
Returns:
|
||||
ModemDisableResult с результатом операции
|
||||
"""
|
||||
try:
|
||||
subscription.modem_enabled = False
|
||||
if subscription.device_limit and subscription.device_limit > 1:
|
||||
subscription.device_limit = subscription.device_limit - 1
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
await self._subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
await db.refresh(user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
user_id_display = user.telegram_id or user.email or f'#{user.id}'
|
||||
logger.info(f'Пользователь {user_id_display} отключил модем')
|
||||
|
||||
return ModemDisableResult(success=True, new_device_limit=subscription.device_limit)
|
||||
|
||||
except Exception as e:
|
||||
user_id_display = user.telegram_id or user.email or f'#{user.id}'
|
||||
logger.error(f'Ошибка отключения модема для пользователя {user_id_display}: {e}')
|
||||
await db.rollback()
|
||||
return ModemDisableResult(success=False, error=ModemError.UPDATE_ERROR)
|
||||
|
||||
@staticmethod
|
||||
def get_period_warning_level(remaining_days: int) -> str | None:
|
||||
"""
|
||||
Определяет уровень предупреждения о сроке действия.
|
||||
|
||||
Args:
|
||||
remaining_days: Оставшиеся дни подписки
|
||||
|
||||
Returns:
|
||||
"critical" если <= 7 дней
|
||||
"info" если <= 30 дней
|
||||
None если больше 30 дней
|
||||
"""
|
||||
if remaining_days <= MODEM_WARNING_DAYS_CRITICAL:
|
||||
return 'critical'
|
||||
if remaining_days <= MODEM_WARNING_DAYS_INFO:
|
||||
return 'info'
|
||||
return None
|
||||
|
||||
|
||||
# Singleton instance для использования в хендлерах
|
||||
_modem_service: ModemService | None = None
|
||||
|
||||
|
||||
def get_modem_service() -> ModemService:
|
||||
"""Возвращает singleton экземпляр ModemService."""
|
||||
global _modem_service
|
||||
if _modem_service is None:
|
||||
_modem_service = ModemService()
|
||||
return _modem_service
|
||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramNetworkError
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -1286,6 +1286,13 @@ class MonitoringService:
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
except TelegramNetworkError as e:
|
||||
logger.warning(
|
||||
'Таймаут отправки уведомления об истечении подписки пользователю %s: %s',
|
||||
user.telegram_id,
|
||||
e,
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка отправки уведомления об истечении подписки пользователю %s: %s',
|
||||
@@ -1335,6 +1342,13 @@ class MonitoringService:
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
except TelegramNetworkError as e:
|
||||
logger.warning(
|
||||
'Таймаут отправки уведомления об окончании тестовой подписки пользователю %s: %s',
|
||||
user.telegram_id,
|
||||
e,
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка отправки уведомления об окончании тестовой подписки пользователю %s: %s',
|
||||
@@ -1410,6 +1424,13 @@ class MonitoringService:
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
except TelegramNetworkError as e:
|
||||
logger.warning(
|
||||
'Таймаут отправки уведомления об отсутствии подключения пользователю %s: %s',
|
||||
user.telegram_id,
|
||||
e,
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка отправки уведомления об отсутствии подключения пользователю %s: %s',
|
||||
@@ -1473,6 +1494,13 @@ class MonitoringService:
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
except TelegramNetworkError as error:
|
||||
logger.warning(
|
||||
'Таймаут отправки уведомления об отписке от канала пользователю %s: %s',
|
||||
user.telegram_id,
|
||||
error,
|
||||
)
|
||||
return False
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Ошибка отправки уведомления об отписке от канала пользователю %s: %s',
|
||||
@@ -1537,6 +1565,13 @@ class MonitoringService:
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
except TelegramNetworkError as e:
|
||||
logger.warning(
|
||||
'Таймаут отправки напоминания об истекшей подписке пользователю %s: %s',
|
||||
user.telegram_id,
|
||||
e,
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка отправки напоминания об истекшей подписке пользователю %s: %s',
|
||||
@@ -1629,6 +1664,13 @@ class MonitoringService:
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
except TelegramNetworkError as e:
|
||||
logger.warning(
|
||||
'Таймаут отправки скидочного уведомления пользователю %s: %s',
|
||||
user.telegram_id,
|
||||
e,
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка отправки скидочного уведомления пользователю %s: %s',
|
||||
@@ -1653,6 +1695,12 @@ class MonitoringService:
|
||||
user.telegram_id,
|
||||
exc,
|
||||
)
|
||||
except TelegramNetworkError as e:
|
||||
logger.warning(
|
||||
'Таймаут отправки уведомления об автоплатеже пользователю %s: %s',
|
||||
user.telegram_id,
|
||||
e,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка отправки уведомления об автоплатеже пользователю %s: %s',
|
||||
@@ -1690,6 +1738,12 @@ class MonitoringService:
|
||||
user.telegram_id,
|
||||
exc,
|
||||
)
|
||||
except TelegramNetworkError as e:
|
||||
logger.warning(
|
||||
'Таймаут отправки уведомления о неудачном автоплатеже пользователю %s: %s',
|
||||
user.telegram_id,
|
||||
e,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка отправки уведомления о неудачном автоплатеже пользователю %s: %s',
|
||||
|
||||
@@ -397,6 +397,21 @@ class YooKassaPaymentMixin:
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.models import YooKassaPayment as YKPayment
|
||||
|
||||
# Lock the payment row to prevent concurrent double-processing
|
||||
locked_result = await db.execute(select(YKPayment).where(YKPayment.id == payment.id).with_for_update())
|
||||
payment = locked_result.scalar_one()
|
||||
|
||||
# Fast-path: already processed
|
||||
if getattr(payment, 'transaction_id', None):
|
||||
logger.info(
|
||||
'Платеж YooKassa %s уже обработан (transaction_id=%s), пропускаем.',
|
||||
payment.yookassa_payment_id,
|
||||
payment.transaction_id,
|
||||
)
|
||||
return True
|
||||
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
|
||||
# Проверяем, не обрабатывается ли уже этот платеж (защита от дублирования)
|
||||
|
||||
@@ -1335,9 +1335,18 @@ class RemnaWaveService:
|
||||
pending_uuid_mutations.clear()
|
||||
try:
|
||||
await db.rollback() # Выполняем rollback при ошибке
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
# After rollback all ORM objects in the session are expired.
|
||||
# Accessing their attributes triggers a lazy load which fails
|
||||
# in async context (greenlet_spawn error). Break the loop to
|
||||
# prevent cascading failures for every remaining user.
|
||||
logger.warning(
|
||||
'⚠️ Сессия повреждена после rollback, прерываем обработку (обработано %d/%d пользователей)',
|
||||
i + 1,
|
||||
len(unique_panel_users),
|
||||
)
|
||||
break
|
||||
|
||||
else:
|
||||
if uuid_mutation and uuid_mutation.has_changes():
|
||||
|
||||
@@ -16,17 +16,20 @@ from typing import Any
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm.exc import StaleDataError
|
||||
|
||||
from app.database.crud.subscription import (
|
||||
deactivate_subscription,
|
||||
decrement_subscription_server_counts,
|
||||
expire_subscription,
|
||||
get_subscription_by_user_id,
|
||||
reactivate_subscription,
|
||||
update_subscription_usage,
|
||||
)
|
||||
from app.database.crud.user import get_user_by_remnawave_uuid, get_user_by_telegram_id
|
||||
from app.database.models import Subscription, SubscriptionStatus, User
|
||||
from app.database.models import Subscription, SubscriptionServer, SubscriptionStatus, User
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.notification_delivery_service import NotificationType, notification_delivery_service
|
||||
@@ -164,11 +167,23 @@ class RemnaWaveWebhookService:
|
||||
)
|
||||
return False
|
||||
|
||||
user_id = user.id
|
||||
try:
|
||||
await handler(db, user, subscription, data)
|
||||
return True
|
||||
except StaleDataError:
|
||||
logger.warning(
|
||||
'RemnaWave webhook %s: entity already deleted for user %s (concurrent deletion)',
|
||||
event_name,
|
||||
user_id,
|
||||
)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception('Error processing RemnaWave webhook event %s for user %s', event_name, user.id)
|
||||
logger.exception('Error processing RemnaWave webhook event %s for user %s', event_name, user_id)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception:
|
||||
@@ -559,22 +574,60 @@ class RemnaWaveWebhookService:
|
||||
async def _handle_user_deleted(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
user_id = user.id
|
||||
sub_id = subscription.id if subscription else None
|
||||
|
||||
if subscription:
|
||||
self._stamp_webhook_update(subscription)
|
||||
|
||||
# Decrement server counters BEFORE clearing connected_squads
|
||||
await decrement_subscription_server_counts(db, subscription)
|
||||
|
||||
# Re-fetch after potential rollback inside decrement_subscription_server_counts
|
||||
try:
|
||||
await db.refresh(subscription)
|
||||
except Exception:
|
||||
# Subscription was cascade-deleted, re-fetch user and skip subscription updates
|
||||
logger.warning(
|
||||
'Webhook: subscription %s already deleted for user %s, skipping subscription cleanup',
|
||||
sub_id,
|
||||
user_id,
|
||||
)
|
||||
subscription = None
|
||||
try:
|
||||
await db.refresh(user)
|
||||
except Exception:
|
||||
from app.database.crud.user import get_user_by_id
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
logger.error('Webhook: user %s not found after rollback', user_id)
|
||||
return
|
||||
|
||||
if subscription:
|
||||
if subscription.status != SubscriptionStatus.EXPIRED.value:
|
||||
await expire_subscription(db, subscription)
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
logger.info(
|
||||
'Webhook: subscription %s marked expired (user deleted in panel) for user %s',
|
||||
subscription.id,
|
||||
user.id,
|
||||
sub_id,
|
||||
user_id,
|
||||
)
|
||||
else:
|
||||
await db.commit()
|
||||
|
||||
# Clear subscription data — panel user no longer exists
|
||||
subscription.subscription_url = None
|
||||
subscription.subscription_crypto_link = None
|
||||
subscription.remnawave_short_uuid = None
|
||||
subscription.connected_squads = None
|
||||
subscription.updated_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
|
||||
# Remove SubscriptionServer link rows
|
||||
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == sub_id))
|
||||
|
||||
# Clear remnawave linkage
|
||||
if user.remnawave_uuid:
|
||||
user.remnawave_uuid = None
|
||||
await db.commit()
|
||||
|
||||
await db.commit()
|
||||
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_DELETED', reply_markup=self._get_renew_keyboard(user))
|
||||
|
||||
|
||||
@@ -336,11 +336,6 @@ 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,
|
||||
@@ -454,7 +449,30 @@ class SubscriptionRenewalService:
|
||||
subscription_before = subscription
|
||||
old_end_date = subscription_before.end_date
|
||||
|
||||
subscription_after = await extend_subscription(db, subscription_before, period_days)
|
||||
try:
|
||||
subscription_after = await extend_subscription(db, subscription_before, period_days)
|
||||
except Exception:
|
||||
# Compensate: refund the charged balance since extension failed
|
||||
if charge_from_balance > 0:
|
||||
try:
|
||||
from app.database.crud.user import add_user_balance
|
||||
|
||||
await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
charge_from_balance,
|
||||
'Возврат: ошибка продления подписки',
|
||||
create_transaction=True,
|
||||
transaction_type=TransactionType.REFUND,
|
||||
)
|
||||
except Exception as refund_error:
|
||||
logger.critical(
|
||||
'CRITICAL: Failed to refund %s kopeks to user %s after extension failure: %s',
|
||||
charge_from_balance,
|
||||
user.id,
|
||||
refund_error,
|
||||
)
|
||||
raise
|
||||
|
||||
server_ids = pricing.server_ids or []
|
||||
server_prices_for_period = pricing.details.get('servers_individual_prices', [])
|
||||
|
||||
@@ -790,11 +790,6 @@ 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,
|
||||
|
||||
@@ -1105,6 +1105,9 @@ class UserService:
|
||||
try:
|
||||
if user.subscription:
|
||||
logger.info(f'🔄 Удаляем подписку {user.subscription.id}')
|
||||
await db.execute(
|
||||
delete(SubscriptionServer).where(SubscriptionServer.subscription_id == user.subscription.id)
|
||||
)
|
||||
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
@@ -47,6 +49,24 @@ class WataService:
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
_MAX_RETRIES = 2
|
||||
|
||||
@staticmethod
|
||||
def _parse_retry_after(response: aiohttp.ClientResponse, response_text: str) -> float:
|
||||
"""Extract retry delay from Retry-After header or response body."""
|
||||
retry_after = response.headers.get('Retry-After')
|
||||
if retry_after:
|
||||
try:
|
||||
return float(retry_after)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
match = re.search(r'[Rr]etry after (\d+)', response_text)
|
||||
if match:
|
||||
return float(match.group(1))
|
||||
|
||||
return 45.0
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
@@ -61,35 +81,57 @@ class WataService:
|
||||
url = self._build_url(path)
|
||||
timeout = aiohttp.ClientTimeout(total=self.request_timeout)
|
||||
|
||||
try:
|
||||
async with (
|
||||
aiohttp.ClientSession(timeout=timeout) as session,
|
||||
session.request(
|
||||
method,
|
||||
url,
|
||||
json=json,
|
||||
params=params,
|
||||
headers=self._build_headers(),
|
||||
) as response,
|
||||
):
|
||||
response_text = await response.text()
|
||||
if response.status >= 400:
|
||||
logger.error('WATA API error %s: %s', response.status, response_text)
|
||||
raise WataAPIError(f'WATA API returned status {response.status}: {response_text}')
|
||||
last_error: WataAPIError | None = None
|
||||
for attempt in range(1 + self._MAX_RETRIES):
|
||||
try:
|
||||
async with (
|
||||
aiohttp.ClientSession(timeout=timeout) as session,
|
||||
session.request(
|
||||
method,
|
||||
url,
|
||||
json=json,
|
||||
params=params,
|
||||
headers=self._build_headers(),
|
||||
) as response,
|
||||
):
|
||||
response_text = await response.text()
|
||||
|
||||
if not response_text:
|
||||
return {}
|
||||
if response.status == 429:
|
||||
retry_delay = self._parse_retry_after(response, response_text)
|
||||
if attempt < self._MAX_RETRIES:
|
||||
logger.warning(
|
||||
'WATA API 429 on %s %s, retry %d/%d after %.0fs',
|
||||
method,
|
||||
path,
|
||||
attempt + 1,
|
||||
self._MAX_RETRIES,
|
||||
retry_delay,
|
||||
)
|
||||
await asyncio.sleep(retry_delay)
|
||||
continue
|
||||
logger.warning('WATA API 429 on %s %s, retries exhausted', method, path)
|
||||
last_error = WataAPIError(f'WATA API rate limited on {method} {path}')
|
||||
break
|
||||
|
||||
try:
|
||||
data = await response.json()
|
||||
except aiohttp.ContentTypeError as error:
|
||||
logger.error('WATA API returned non-JSON response: %s', error)
|
||||
raise WataAPIError('WATA API returned invalid JSON') from error
|
||||
if response.status >= 400:
|
||||
logger.error('WATA API error %s: %s', response.status, response_text)
|
||||
raise WataAPIError(f'WATA API returned status {response.status}: {response_text}')
|
||||
|
||||
return data
|
||||
except aiohttp.ClientError as error:
|
||||
logger.error('Error communicating with WATA API: %s', error)
|
||||
raise WataAPIError('Failed to communicate with WATA API') from error
|
||||
if not response_text:
|
||||
return {}
|
||||
|
||||
try:
|
||||
data = await response.json()
|
||||
except aiohttp.ContentTypeError as error:
|
||||
logger.error('WATA API returned non-JSON response: %s', error)
|
||||
raise WataAPIError('WATA API returned invalid JSON') from error
|
||||
|
||||
return data
|
||||
except aiohttp.ClientError as error:
|
||||
logger.error('Error communicating with WATA API: %s', error)
|
||||
raise WataAPIError('Failed to communicate with WATA API') from error
|
||||
|
||||
raise last_error or WataAPIError('WATA API request failed')
|
||||
|
||||
@staticmethod
|
||||
def _amount_from_kopeks(amount_kopeks: int) -> float:
|
||||
|
||||
@@ -173,75 +173,3 @@ def rate_limit(rate: float = 1.0, key: str = None):
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def modem_available(for_enable: bool = False, for_disable: bool = False):
|
||||
"""
|
||||
Декоратор для проверки доступности модема.
|
||||
|
||||
Проверяет:
|
||||
- Наличие подписки
|
||||
- Подписка не триальная
|
||||
- Функция модема включена в настройках
|
||||
- (опционально) Модем ещё не подключен (for_enable=True)
|
||||
- (опционально) Модем уже подключен (for_disable=True)
|
||||
|
||||
Args:
|
||||
for_enable: Проверять, что модем ещё не подключен
|
||||
for_disable: Проверять, что модем подключен
|
||||
|
||||
Usage:
|
||||
@modem_available()
|
||||
async def handle_modem_menu(callback, db_user, db): ...
|
||||
|
||||
@modem_available(for_enable=True)
|
||||
async def handle_modem_enable(callback, db_user, db): ...
|
||||
"""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
async def wrapper(event: types.Update, *args, **kwargs) -> Any:
|
||||
db_user = kwargs.get('db_user')
|
||||
|
||||
if not db_user:
|
||||
logger.warning('modem_available: нет db_user в kwargs')
|
||||
return None
|
||||
|
||||
from app.services.modem_service import ModemError, get_modem_service
|
||||
|
||||
service = get_modem_service()
|
||||
result = service.check_availability(db_user, for_enable=for_enable, for_disable=for_disable)
|
||||
|
||||
if not result.available:
|
||||
texts = get_texts(db_user.language if db_user else 'ru')
|
||||
|
||||
error_messages = {
|
||||
ModemError.NO_SUBSCRIPTION: texts.t(
|
||||
'MODEM_PAID_ONLY', 'Модем доступен только для платных подписок'
|
||||
),
|
||||
ModemError.TRIAL_SUBSCRIPTION: texts.t(
|
||||
'MODEM_PAID_ONLY', 'Модем доступен только для платных подписок'
|
||||
),
|
||||
ModemError.MODEM_DISABLED: texts.t('MODEM_DISABLED', 'Функция модема отключена'),
|
||||
ModemError.ALREADY_ENABLED: texts.t('MODEM_ALREADY_ENABLED', 'Модем уже подключен'),
|
||||
ModemError.NOT_ENABLED: texts.t('MODEM_NOT_ENABLED', 'Модем не подключен'),
|
||||
}
|
||||
|
||||
error_text = error_messages.get(result.error, texts.ERROR)
|
||||
|
||||
try:
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
await event.answer(error_text, show_alert=True)
|
||||
elif isinstance(event, types.Message):
|
||||
await event.answer(error_text)
|
||||
except TelegramBadRequest as e:
|
||||
if 'query is too old' not in str(e).lower():
|
||||
raise
|
||||
|
||||
return None
|
||||
|
||||
return await func(event, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
@@ -4,32 +4,28 @@ from aiogram.types import InlineKeyboardButton
|
||||
from app.config import settings
|
||||
|
||||
|
||||
DEFAULT_UNAVAILABLE_CALLBACK = 'menu_profile_unavailable'
|
||||
|
||||
|
||||
def build_miniapp_or_callback_button(
|
||||
text: str,
|
||||
*,
|
||||
callback_data: str,
|
||||
unavailable_callback: str = DEFAULT_UNAVAILABLE_CALLBACK,
|
||||
) -> InlineKeyboardButton:
|
||||
"""Create a button that opens the miniapp in text menu mode.
|
||||
"""Create a button that opens the miniapp or falls back to a callback.
|
||||
|
||||
When the simplified text menu mode is enabled we should avoid exposing
|
||||
deep bot flows and redirect the user to the configured miniapp instead.
|
||||
If the miniapp URL is missing we fall back to a safe callback that shows
|
||||
an alert about the unavailable profile rather than opening disabled
|
||||
sections of the bot.
|
||||
In text menu mode, if ``MINIAPP_CUSTOM_URL`` is configured the button
|
||||
opens the full cabinet miniapp. Otherwise (or outside text menu mode)
|
||||
the regular ``callback_data`` is used so the user stays in the bot.
|
||||
|
||||
Only ``MINIAPP_CUSTOM_URL`` is considered here — the purchase-only URL
|
||||
(``MINIAPP_PURCHASE_URL``) is intentionally excluded because it cannot
|
||||
display subscription details and would load indefinitely.
|
||||
"""
|
||||
|
||||
if settings.is_text_main_menu_mode():
|
||||
miniapp_url = settings.get_main_menu_miniapp_url()
|
||||
miniapp_url = (settings.MINIAPP_CUSTOM_URL or '').strip()
|
||||
if miniapp_url:
|
||||
return InlineKeyboardButton(
|
||||
text=text,
|
||||
web_app=types.WebAppInfo(url=miniapp_url),
|
||||
)
|
||||
safe_callback = unavailable_callback or DEFAULT_UNAVAILABLE_CALLBACK
|
||||
return InlineKeyboardButton(text=text, callback_data=safe_callback)
|
||||
|
||||
return InlineKeyboardButton(text=text, callback_data=callback_data)
|
||||
|
||||
@@ -129,14 +129,6 @@ async def compute_simple_subscription_price(
|
||||
additional_devices = max(0, device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_original = additional_devices * settings.PRICE_PER_DEVICE
|
||||
|
||||
# Расчёт цены модема (если включён)
|
||||
modem_enabled = params.get('modem_enabled', False)
|
||||
modem_price_original = 0
|
||||
if modem_enabled and settings.is_modem_enabled():
|
||||
modem_price_per_month = settings.get_modem_price_per_month()
|
||||
months = calculate_months_from_days(period_days)
|
||||
modem_price_original = modem_price_per_month * months
|
||||
|
||||
promo_group: PromoGroup | None = params.get('promo_group')
|
||||
|
||||
if promo_group is None:
|
||||
@@ -256,11 +248,7 @@ async def compute_simple_subscription_price(
|
||||
)
|
||||
|
||||
total_before_discount = (
|
||||
base_price_original
|
||||
+ traffic_price_original
|
||||
+ devices_price_original
|
||||
+ servers_price_original
|
||||
+ modem_price_original
|
||||
base_price_original + traffic_price_original + devices_price_original + servers_price_original
|
||||
)
|
||||
|
||||
total_discount = base_discount + traffic_discount + devices_discount + servers_discount_total
|
||||
@@ -274,8 +262,6 @@ async def compute_simple_subscription_price(
|
||||
'traffic_discount': traffic_discount,
|
||||
'devices_price': devices_price_original,
|
||||
'devices_discount': devices_discount,
|
||||
'modem_price': modem_price_original,
|
||||
'modem_enabled': modem_enabled,
|
||||
'servers_price': servers_price_original,
|
||||
'servers_discount': servers_discount_total,
|
||||
'servers_final': sum(item['final_price'] for item in server_breakdown),
|
||||
|
||||
@@ -5926,7 +5926,10 @@ async def update_subscription_servers_endpoint(
|
||||
|
||||
if added_server_ids:
|
||||
await add_subscription_servers(db, subscription, added_server_ids, added_server_prices)
|
||||
await add_user_to_servers(db, added_server_ids)
|
||||
try:
|
||||
await add_user_to_servers(db, added_server_ids)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка обновления счётчика серверов (add): {e}')
|
||||
|
||||
removed_server_ids = [
|
||||
catalog[uuid].get('server_id') for uuid in removed if catalog[uuid].get('server_id') is not None
|
||||
@@ -5934,7 +5937,10 @@ async def update_subscription_servers_endpoint(
|
||||
|
||||
if removed_server_ids:
|
||||
await remove_subscription_servers(db, subscription.id, removed_server_ids)
|
||||
await remove_user_from_servers(db, removed_server_ids)
|
||||
try:
|
||||
await remove_user_from_servers(db, removed_server_ids)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка обновления счётчика серверов (remove): {e}')
|
||||
|
||||
ordered_selection = []
|
||||
seen_selection = set()
|
||||
|
||||
@@ -30,7 +30,6 @@ from ..schemas.subscriptions import (
|
||||
SubscriptionCreateRequest,
|
||||
SubscriptionDevicesRequest,
|
||||
SubscriptionExtendRequest,
|
||||
SubscriptionModemRequest,
|
||||
SubscriptionResponse,
|
||||
SubscriptionSquadRequest,
|
||||
SubscriptionTrafficRequest,
|
||||
@@ -54,7 +53,6 @@ def _serialize_subscription(subscription: Subscription) -> SubscriptionResponse:
|
||||
traffic_limit_gb=subscription.traffic_limit_gb,
|
||||
traffic_used_gb=subscription.traffic_used_gb,
|
||||
device_limit=subscription.device_limit,
|
||||
modem_enabled=getattr(subscription, 'modem_enabled', False) or False,
|
||||
autopay_enabled=subscription.autopay_enabled,
|
||||
autopay_days_before=subscription.autopay_days_before,
|
||||
subscription_url=subscription.subscription_url,
|
||||
@@ -323,41 +321,3 @@ async def delete_subscription(
|
||||
|
||||
subscription = await _get_subscription(db, subscription.id)
|
||||
return _serialize_subscription(subscription)
|
||||
|
||||
|
||||
@router.post('/{subscription_id}/modem', response_model=SubscriptionResponse)
|
||||
async def set_subscription_modem(
|
||||
subscription_id: int,
|
||||
payload: SubscriptionModemRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SubscriptionResponse:
|
||||
"""Включить или выключить модем для подписки."""
|
||||
subscription = await _get_subscription(db, subscription_id)
|
||||
|
||||
if subscription.is_trial:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Modem is not available for trial subscriptions')
|
||||
|
||||
if not settings.is_modem_enabled():
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Modem feature is disabled')
|
||||
|
||||
current_modem = getattr(subscription, 'modem_enabled', False) or False
|
||||
|
||||
if payload.enabled == current_modem:
|
||||
return _serialize_subscription(subscription)
|
||||
|
||||
if payload.enabled:
|
||||
subscription.modem_enabled = True
|
||||
subscription.device_limit = (subscription.device_limit or 1) + 1
|
||||
else:
|
||||
subscription.modem_enabled = False
|
||||
if subscription.device_limit and subscription.device_limit > 1:
|
||||
subscription.device_limit = subscription.device_limit - 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
subscription = await _get_subscription(db, subscription.id)
|
||||
return _serialize_subscription(subscription)
|
||||
|
||||
@@ -70,7 +70,6 @@ def _serialize_subscription(subscription: Subscription | None) -> SubscriptionSu
|
||||
traffic_limit_gb=subscription.traffic_limit_gb,
|
||||
traffic_used_gb=subscription.traffic_used_gb,
|
||||
device_limit=subscription.device_limit,
|
||||
modem_enabled=getattr(subscription, 'modem_enabled', False) or False,
|
||||
autopay_enabled=subscription.autopay_enabled,
|
||||
autopay_days_before=subscription.autopay_days_before,
|
||||
subscription_url=subscription.subscription_url,
|
||||
|
||||
@@ -16,7 +16,6 @@ class SubscriptionResponse(BaseModel):
|
||||
traffic_limit_gb: int
|
||||
traffic_used_gb: float
|
||||
device_limit: int
|
||||
modem_enabled: bool = False
|
||||
autopay_enabled: bool
|
||||
autopay_days_before: int | None = None
|
||||
subscription_url: str | None = None
|
||||
@@ -51,7 +50,3 @@ class SubscriptionDevicesRequest(BaseModel):
|
||||
|
||||
class SubscriptionSquadRequest(BaseModel):
|
||||
squad_uuid: str
|
||||
|
||||
|
||||
class SubscriptionModemRequest(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
@@ -24,7 +24,6 @@ class SubscriptionSummary(BaseModel):
|
||||
traffic_limit_gb: int
|
||||
traffic_used_gb: float
|
||||
device_limit: int
|
||||
modem_enabled: bool = False
|
||||
autopay_enabled: bool
|
||||
autopay_days_before: int | None = None
|
||||
subscription_url: str | None = None
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = 'remnawave-bedolaga-telegram-bot'
|
||||
version = "3.10.0"
|
||||
version = "3.10.2"
|
||||
description = 'Telegram bot for RemnaWave VPN service'
|
||||
readme = 'README.md'
|
||||
license = { text = 'MIT' }
|
||||
|
||||
@@ -1,395 +0,0 @@
|
||||
"""
|
||||
Тесты для ModemService - управление модемом в подписке.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from app.services.modem_service import (
|
||||
ModemError,
|
||||
ModemService,
|
||||
get_modem_service,
|
||||
)
|
||||
|
||||
|
||||
def create_mock_settings():
|
||||
"""Создаёт мок настроек приложения."""
|
||||
settings = MagicMock()
|
||||
settings.is_modem_enabled.return_value = True
|
||||
settings.get_modem_price_per_month.return_value = 10000 # 100 рублей
|
||||
settings.get_modem_period_discount.return_value = 0
|
||||
return settings
|
||||
|
||||
|
||||
def create_sample_user():
|
||||
"""Создаёт пример пользователя."""
|
||||
user = SimpleNamespace(
|
||||
id=1,
|
||||
telegram_id=123456789,
|
||||
balance_kopeks=50000, # 500 рублей
|
||||
language='ru',
|
||||
subscription=None,
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def create_sample_subscription():
|
||||
"""Создаёт пример подписки."""
|
||||
subscription = SimpleNamespace(
|
||||
id=1,
|
||||
user_id=1,
|
||||
is_trial=False,
|
||||
modem_enabled=False,
|
||||
device_limit=2,
|
||||
end_date=datetime.utcnow() + timedelta(days=30),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
return subscription
|
||||
|
||||
|
||||
def create_trial_subscription():
|
||||
"""Создаёт триальную подписку."""
|
||||
subscription = SimpleNamespace(
|
||||
id=2,
|
||||
user_id=1,
|
||||
is_trial=True,
|
||||
modem_enabled=False,
|
||||
device_limit=1,
|
||||
end_date=datetime.utcnow() + timedelta(days=7),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
return subscription
|
||||
|
||||
|
||||
def create_modem_service(monkeypatch):
|
||||
"""Создаёт ModemService с замоканными настройками."""
|
||||
mock_settings = create_mock_settings()
|
||||
monkeypatch.setattr('app.services.modem_service.settings', mock_settings)
|
||||
return ModemService(), mock_settings
|
||||
|
||||
|
||||
class TestModemServiceAvailability:
|
||||
"""Тесты проверки доступности модема."""
|
||||
|
||||
def test_check_availability_no_subscription(self, monkeypatch):
|
||||
"""Модем недоступен без подписки."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_user.subscription = None
|
||||
|
||||
result = modem_service.check_availability(sample_user)
|
||||
|
||||
assert not result.available
|
||||
assert result.error == ModemError.NO_SUBSCRIPTION
|
||||
assert not result.modem_enabled
|
||||
|
||||
def test_check_availability_trial_subscription(self, monkeypatch):
|
||||
"""Модем недоступен для триальной подписки."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
trial_subscription = create_trial_subscription()
|
||||
sample_user.subscription = trial_subscription
|
||||
|
||||
result = modem_service.check_availability(sample_user)
|
||||
|
||||
assert not result.available
|
||||
assert result.error == ModemError.TRIAL_SUBSCRIPTION
|
||||
assert not result.modem_enabled
|
||||
|
||||
def test_check_availability_modem_disabled_in_settings(self, monkeypatch):
|
||||
"""Модем недоступен, если отключён в настройках."""
|
||||
modem_service, mock_settings = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_user.subscription = sample_subscription
|
||||
mock_settings.is_modem_enabled.return_value = False
|
||||
|
||||
result = modem_service.check_availability(sample_user)
|
||||
|
||||
assert not result.available
|
||||
assert result.error == ModemError.MODEM_DISABLED
|
||||
|
||||
def test_check_availability_success(self, monkeypatch):
|
||||
"""Модем доступен для платной подписки."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_user.subscription = sample_subscription
|
||||
|
||||
result = modem_service.check_availability(sample_user)
|
||||
|
||||
assert result.available
|
||||
assert result.error is None
|
||||
assert not result.modem_enabled
|
||||
|
||||
def test_check_availability_for_enable_already_enabled(self, monkeypatch):
|
||||
"""Нельзя подключить уже подключенный модем."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_subscription.modem_enabled = True
|
||||
sample_user.subscription = sample_subscription
|
||||
|
||||
result = modem_service.check_availability(sample_user, for_enable=True)
|
||||
|
||||
assert not result.available
|
||||
assert result.error == ModemError.ALREADY_ENABLED
|
||||
assert result.modem_enabled
|
||||
|
||||
def test_check_availability_for_disable_not_enabled(self, monkeypatch):
|
||||
"""Нельзя отключить неподключенный модем."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_subscription.modem_enabled = False
|
||||
sample_user.subscription = sample_subscription
|
||||
|
||||
result = modem_service.check_availability(sample_user, for_disable=True)
|
||||
|
||||
assert not result.available
|
||||
assert result.error == ModemError.NOT_ENABLED
|
||||
assert not result.modem_enabled
|
||||
|
||||
|
||||
class TestModemServicePricing:
|
||||
"""Тесты расчёта цены модема."""
|
||||
|
||||
def test_calculate_price_one_month(self, monkeypatch):
|
||||
"""Расчёт цены на 1 месяц."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_subscription.end_date = datetime.utcnow() + timedelta(days=30)
|
||||
|
||||
result = modem_service.calculate_price(sample_subscription)
|
||||
|
||||
assert result.base_price == 10000
|
||||
assert result.final_price == 10000
|
||||
assert result.charged_months == 1
|
||||
assert result.discount_percent == 0
|
||||
assert not result.has_discount
|
||||
|
||||
def test_calculate_price_three_months(self, monkeypatch):
|
||||
"""Расчёт цены на 3 месяца."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_subscription.end_date = datetime.utcnow() + timedelta(days=90)
|
||||
|
||||
result = modem_service.calculate_price(sample_subscription)
|
||||
|
||||
assert result.base_price == 30000 # 3 * 10000
|
||||
assert result.charged_months == 3
|
||||
|
||||
def test_calculate_price_with_discount(self, monkeypatch):
|
||||
"""Расчёт цены со скидкой."""
|
||||
modem_service, mock_settings = create_modem_service(monkeypatch)
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_subscription.end_date = datetime.utcnow() + timedelta(days=90)
|
||||
mock_settings.get_modem_period_discount.return_value = 10 # 10% скидка
|
||||
|
||||
result = modem_service.calculate_price(sample_subscription)
|
||||
|
||||
assert result.base_price == 30000
|
||||
assert result.discount_percent == 10
|
||||
assert result.discount_amount == 3000
|
||||
assert result.final_price == 27000
|
||||
assert result.has_discount
|
||||
|
||||
|
||||
class TestModemServiceBalance:
|
||||
"""Тесты проверки баланса."""
|
||||
|
||||
def test_check_balance_sufficient(self, monkeypatch):
|
||||
"""Баланса достаточно."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_user.balance_kopeks = 50000
|
||||
|
||||
has_funds, missing = modem_service.check_balance(sample_user, 10000)
|
||||
|
||||
assert has_funds
|
||||
assert missing == 0
|
||||
|
||||
def test_check_balance_insufficient(self, monkeypatch):
|
||||
"""Баланса недостаточно."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_user.balance_kopeks = 5000
|
||||
|
||||
has_funds, missing = modem_service.check_balance(sample_user, 10000)
|
||||
|
||||
assert not has_funds
|
||||
assert missing == 5000
|
||||
|
||||
def test_check_balance_zero_price(self, monkeypatch):
|
||||
"""Нулевая цена - всегда достаточно."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_user.balance_kopeks = 0
|
||||
|
||||
has_funds, missing = modem_service.check_balance(sample_user, 0)
|
||||
|
||||
assert has_funds
|
||||
assert missing == 0
|
||||
|
||||
|
||||
class TestModemServicePeriodWarning:
|
||||
"""Тесты предупреждений о сроке действия."""
|
||||
|
||||
def test_warning_critical(self, monkeypatch):
|
||||
"""Критическое предупреждение при <= 7 днях."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
assert modem_service.get_period_warning_level(7) == 'critical'
|
||||
assert modem_service.get_period_warning_level(5) == 'critical'
|
||||
assert modem_service.get_period_warning_level(1) == 'critical'
|
||||
|
||||
def test_warning_info(self, monkeypatch):
|
||||
"""Информационное предупреждение при <= 30 днях."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
assert modem_service.get_period_warning_level(30) == 'info'
|
||||
assert modem_service.get_period_warning_level(15) == 'info'
|
||||
assert modem_service.get_period_warning_level(8) == 'info'
|
||||
|
||||
def test_warning_none(self, monkeypatch):
|
||||
"""Нет предупреждения при > 30 днях."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
assert modem_service.get_period_warning_level(31) is None
|
||||
assert modem_service.get_period_warning_level(60) is None
|
||||
assert modem_service.get_period_warning_level(90) is None
|
||||
|
||||
|
||||
class TestModemServiceEnable:
|
||||
"""Тесты подключения модема."""
|
||||
|
||||
async def test_enable_modem_success(self, monkeypatch):
|
||||
"""Успешное подключение модема."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_user.subscription = sample_subscription
|
||||
sample_user.balance_kopeks = 50000
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_subtract = AsyncMock(return_value=True)
|
||||
mock_create_transaction = AsyncMock()
|
||||
mock_update_remnawave = AsyncMock()
|
||||
|
||||
monkeypatch.setattr('app.services.modem_service.subtract_user_balance', mock_subtract)
|
||||
monkeypatch.setattr('app.services.modem_service.create_transaction', mock_create_transaction)
|
||||
modem_service._subscription_service.update_remnawave_user = mock_update_remnawave
|
||||
|
||||
result = await modem_service.enable_modem(mock_db, sample_user, sample_subscription)
|
||||
|
||||
assert result.success
|
||||
assert result.error is None
|
||||
assert result.charged_amount == 10000
|
||||
assert sample_subscription.modem_enabled is True
|
||||
assert sample_subscription.device_limit == 3 # было 2, стало 3
|
||||
|
||||
async def test_enable_modem_insufficient_funds(self, monkeypatch):
|
||||
"""Недостаточно средств для подключения."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_user.subscription = sample_subscription
|
||||
sample_user.balance_kopeks = 1000 # недостаточно
|
||||
|
||||
mock_db = AsyncMock()
|
||||
|
||||
result = await modem_service.enable_modem(mock_db, sample_user, sample_subscription)
|
||||
|
||||
assert not result.success
|
||||
assert result.error == ModemError.INSUFFICIENT_FUNDS
|
||||
|
||||
async def test_enable_modem_charge_error(self, monkeypatch):
|
||||
"""Ошибка списания средств."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_user.subscription = sample_subscription
|
||||
sample_user.balance_kopeks = 50000
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_subtract = AsyncMock(return_value=False) # ошибка списания
|
||||
|
||||
monkeypatch.setattr('app.services.modem_service.subtract_user_balance', mock_subtract)
|
||||
|
||||
result = await modem_service.enable_modem(mock_db, sample_user, sample_subscription)
|
||||
|
||||
assert not result.success
|
||||
assert result.error == ModemError.CHARGE_ERROR
|
||||
|
||||
|
||||
class TestModemServiceDisable:
|
||||
"""Тесты отключения модема."""
|
||||
|
||||
async def test_disable_modem_success(self, monkeypatch):
|
||||
"""Успешное отключение модема."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_subscription.modem_enabled = True
|
||||
sample_subscription.device_limit = 3
|
||||
sample_user.subscription = sample_subscription
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_update_remnawave = AsyncMock()
|
||||
modem_service._subscription_service.update_remnawave_user = mock_update_remnawave
|
||||
|
||||
result = await modem_service.disable_modem(mock_db, sample_user, sample_subscription)
|
||||
|
||||
assert result.success
|
||||
assert result.error is None
|
||||
assert sample_subscription.modem_enabled is False
|
||||
assert sample_subscription.device_limit == 2 # было 3, стало 2
|
||||
|
||||
|
||||
class TestModemServiceSingleton:
|
||||
"""Тесты singleton паттерна."""
|
||||
|
||||
def test_get_modem_service_returns_same_instance(self, monkeypatch):
|
||||
"""get_modem_service возвращает один и тот же экземпляр."""
|
||||
# Сбрасываем глобальный экземпляр
|
||||
import app.services.modem_service as modem_module
|
||||
|
||||
modem_module._modem_service = None
|
||||
|
||||
mock_settings = create_mock_settings()
|
||||
monkeypatch.setattr('app.services.modem_service.settings', mock_settings)
|
||||
|
||||
service1 = get_modem_service()
|
||||
service2 = get_modem_service()
|
||||
|
||||
assert service1 is service2
|
||||
|
||||
|
||||
class TestModemEnabledGetter:
|
||||
"""Тесты безопасного получения статуса модема."""
|
||||
|
||||
def test_get_modem_enabled_true(self, monkeypatch):
|
||||
"""Модем включён."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_subscription.modem_enabled = True
|
||||
|
||||
assert modem_service.get_modem_enabled(sample_subscription) is True
|
||||
|
||||
def test_get_modem_enabled_false(self, monkeypatch):
|
||||
"""Модем выключен."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_subscription.modem_enabled = False
|
||||
|
||||
assert modem_service.get_modem_enabled(sample_subscription) is False
|
||||
|
||||
def test_get_modem_enabled_none_subscription(self, monkeypatch):
|
||||
"""Подписка None."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
assert modem_service.get_modem_enabled(None) is False
|
||||
|
||||
def test_get_modem_enabled_no_attribute(self, monkeypatch):
|
||||
"""У подписки нет атрибута modem_enabled."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
subscription = SimpleNamespace(id=1) # без modem_enabled
|
||||
|
||||
assert modem_service.get_modem_enabled(subscription) is False
|
||||
Reference in New Issue
Block a user