mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-22 04:12:09 +00:00
refactor: remove modem functionality from classic subscriptions
Remove all modem purchase/management code: - Delete modem handler, service, and tests - Remove modem button from keyboards and admin panel - Remove modem pricing from calculations - Remove modem REST API endpoint and schemas - Remove modem decorator, config settings, and notification formatting - Keep DB column and migration for backwards compatibility
This commit is contained in:
@@ -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 = ''
|
||||
|
||||
@@ -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 - главный переключатель платной активации
|
||||
# Если выключен - триал бесплатный, независимо от цены
|
||||
|
||||
@@ -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(
|
||||
@@ -3639,63 +3629,7 @@ 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 +5512,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,
|
||||
|
||||
@@ -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')
|
||||
@@ -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(
|
||||
@@ -3065,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),
|
||||
@@ -4118,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
|
||||
|
||||
@@ -4157,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)
|
||||
|
||||
@@ -4174,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
|
||||
|
||||
@@ -4294,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
|
||||
@@ -4312,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,
|
||||
@@ -4321,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,10 +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(
|
||||
[
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -175,73 +175,4 @@ def rate_limit(rate: float = 1.0, key: str = None):
|
||||
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
|
||||
|
||||
@@ -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:
|
||||
@@ -260,7 +252,6 @@ async def compute_simple_subscription_price(
|
||||
+ traffic_price_original
|
||||
+ devices_price_original
|
||||
+ servers_price_original
|
||||
+ modem_price_original
|
||||
)
|
||||
|
||||
total_discount = base_discount + traffic_discount + devices_discount + servers_discount_total
|
||||
@@ -274,8 +265,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),
|
||||
|
||||
@@ -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,
|
||||
@@ -325,39 +323,3 @@ async def delete_subscription(
|
||||
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
|
||||
@@ -53,5 +52,3 @@ 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,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