From ee2e79db3114fe7a9852d2cd33c4b4fbbde311ea Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 11 Feb 2026 21:14:08 +0300 Subject: [PATCH] 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 --- app/config.py | 64 --- app/handlers/admin/users.py | 68 ---- app/handlers/simple_subscription.py | 12 +- app/handlers/subscription/__init__.py | 12 - app/handlers/subscription/modem.py | 323 --------------- app/handlers/subscription/purchase.py | 43 +- app/keyboards/inline.py | 4 - app/services/admin_notification_service.py | 7 - app/services/modem_service.py | 349 ---------------- app/services/subscription_renewal_service.py | 5 - app/services/subscription_service.py | 5 - app/utils/decorators.py | 69 ---- app/utils/pricing_utils.py | 11 - app/webapi/routes/subscriptions.py | 38 -- app/webapi/routes/users.py | 1 - app/webapi/schemas/subscriptions.py | 3 - app/webapi/schemas/users.py | 1 - tests/services/test_modem_service.py | 395 ------------------- 18 files changed, 4 insertions(+), 1406 deletions(-) delete mode 100644 app/handlers/subscription/modem.py delete mode 100644 app/services/modem_service.py delete mode 100644 tests/services/test_modem_service.py diff --git a/app/config.py b/app/config.py index e81dcb7c..8deced57 100644 --- a/app/config.py +++ b/app/config.py @@ -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 - главный переключатель платной активации # Если выключен - триал бесплатный, независимо от цены diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index b6982701..94d3f117 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -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'📡 Модем {action_text}\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_')) diff --git a/app/handlers/simple_subscription.py b/app/handlers/simple_subscription.py index 4e046781..095fd2c8 100644 --- a/app/handlers/simple_subscription.py +++ b/app/handlers/simple_subscription.py @@ -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, diff --git a/app/handlers/subscription/__init__.py b/app/handlers/subscription/__init__.py index 9585cb97..8d4b3637 100644 --- a/app/handlers/subscription/__init__.py +++ b/app/handlers/subscription/__init__.py @@ -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', diff --git a/app/handlers/subscription/modem.py b/app/handlers/subscription/modem.py deleted file mode 100644 index d65e0546..00000000 --- a/app/handlers/subscription/modem.py +++ /dev/null @@ -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', - ( - 'Модем\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', - ( - 'Модем\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', - ( - 'Недостаточно средств\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Внимание! До окончания подписки осталось всего {days} дн.\n' - 'После продления подписки модем нужно будет оплатить заново!', - ).format(days=price_info.remaining_days) - elif warning_level == 'info': - warning_text = texts.t( - 'MODEM_PERIOD_NOTE', - '\nДо окончания подписки: {days} дн.\nПосле продления модем нужно будет оплатить заново.', - ).format(days=price_info.remaining_days) - else: - warning_text = '' - - if price_info.has_discount: - price_text = texts.t( - 'MODEM_PRICE_WITH_DISCOUNT', - 'Стоимость: {base_price} {final_price} (за {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', - ( - 'Подтверждение подключения модема\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Модем действует до: {end_date} ({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', - ('Модем успешно подключен!\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', - ('Модем отключен\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') diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 723d0d4f..7c714850 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -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, ) # Проверяем баланс пользователя diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 30938555..8ef7adae 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -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( [ diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py index 297f016e..29e3267d 100644 --- a/app/services/admin_notification_service.py +++ b/app/services/admin_notification_service.py @@ -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( diff --git a/app/services/modem_service.py b/app/services/modem_service.py deleted file mode 100644 index d410ebcb..00000000 --- a/app/services/modem_service.py +++ /dev/null @@ -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 diff --git a/app/services/subscription_renewal_service.py b/app/services/subscription_renewal_service.py index 7a79333b..12ff3782 100644 --- a/app/services/subscription_renewal_service.py +++ b/app/services/subscription_renewal_service.py @@ -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, diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index e8d37dcc..c5f99548 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -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, diff --git a/app/utils/decorators.py b/app/utils/decorators.py index 6f917634..646b0b75 100644 --- a/app/utils/decorators.py +++ b/app/utils/decorators.py @@ -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 diff --git a/app/utils/pricing_utils.py b/app/utils/pricing_utils.py index 5993f4bd..f5175358 100644 --- a/app/utils/pricing_utils.py +++ b/app/utils/pricing_utils.py @@ -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), diff --git a/app/webapi/routes/subscriptions.py b/app/webapi/routes/subscriptions.py index 979a34a2..d694a25e 100644 --- a/app/webapi/routes/subscriptions.py +++ b/app/webapi/routes/subscriptions.py @@ -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) diff --git a/app/webapi/routes/users.py b/app/webapi/routes/users.py index 5dda9800..e5c7c28e 100644 --- a/app/webapi/routes/users.py +++ b/app/webapi/routes/users.py @@ -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, diff --git a/app/webapi/schemas/subscriptions.py b/app/webapi/schemas/subscriptions.py index 811cff60..aea035c4 100644 --- a/app/webapi/schemas/subscriptions.py +++ b/app/webapi/schemas/subscriptions.py @@ -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 diff --git a/app/webapi/schemas/users.py b/app/webapi/schemas/users.py index 0d3ff5ac..1154c06f 100644 --- a/app/webapi/schemas/users.py +++ b/app/webapi/schemas/users.py @@ -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 diff --git a/tests/services/test_modem_service.py b/tests/services/test_modem_service.py deleted file mode 100644 index 9f2dc33c..00000000 --- a/tests/services/test_modem_service.py +++ /dev/null @@ -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