diff --git a/app/cabinet/routes/admin_tariffs.py b/app/cabinet/routes/admin_tariffs.py index ac116c9a..a03e39ba 100644 --- a/app/cabinet/routes/admin_tariffs.py +++ b/app/cabinet/routes/admin_tariffs.py @@ -218,6 +218,8 @@ async def get_tariff( # Дневной тариф is_daily=tariff.is_daily, daily_price_kopeks=tariff.daily_price_kopeks, + # Режим сброса трафика + traffic_reset_mode=tariff.traffic_reset_mode, created_at=tariff.created_at, updated_at=tariff.updated_at, ) @@ -268,6 +270,8 @@ async def create_new_tariff( # Дневной тариф is_daily=request.is_daily, daily_price_kopeks=request.daily_price_kopeks, + # Режим сброса трафика + traffic_reset_mode=request.traffic_reset_mode, ) logger.info(f"Admin {admin.id} created tariff {tariff.id}: {tariff.name}") @@ -354,6 +358,9 @@ async def update_existing_tariff( updates["is_daily"] = request.is_daily if request.daily_price_kopeks is not None: updates["daily_price_kopeks"] = request.daily_price_kopeks + # Режим сброса трафика (None допускается как значение для сброса к глобальной настройке) + if 'traffic_reset_mode' in request.model_fields_set: + updates["traffic_reset_mode"] = request.traffic_reset_mode if updates: await update_tariff(db, tariff, **updates) diff --git a/app/cabinet/schemas/tariffs.py b/app/cabinet/schemas/tariffs.py index 43d5ab3c..69bb052e 100644 --- a/app/cabinet/schemas/tariffs.py +++ b/app/cabinet/schemas/tariffs.py @@ -103,6 +103,8 @@ class TariffDetailResponse(BaseModel): # Дневной тариф is_daily: bool = False daily_price_kopeks: int = 0 + # Режим сброса трафика + traffic_reset_mode: Optional[str] = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка created_at: datetime updated_at: Optional[datetime] = None @@ -141,6 +143,8 @@ class TariffCreateRequest(BaseModel): # Дневной тариф is_daily: bool = False daily_price_kopeks: int = Field(0, ge=0) + # Режим сброса трафика + traffic_reset_mode: Optional[str] = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка class TariffUpdateRequest(BaseModel): @@ -175,6 +179,8 @@ class TariffUpdateRequest(BaseModel): # Дневной тариф is_daily: Optional[bool] = None daily_price_kopeks: Optional[int] = Field(None, ge=0) + # Режим сброса трафика + traffic_reset_mode: Optional[str] = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка class TariffToggleResponse(BaseModel): diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index cb5b8582..d23f23d6 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -24,10 +24,13 @@ logger = logging.getLogger(__name__) async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Optional[Subscription]: result = await db.execute( select(Subscription) - .options(selectinload(Subscription.user)) + .options( + selectinload(Subscription.user), + selectinload(Subscription.tariff), + ) .where(Subscription.user_id == user_id) .order_by(Subscription.created_at.desc()) - .limit(1) + .limit(1) ) subscription = result.scalar_one_or_none() diff --git a/app/database/crud/tariff.py b/app/database/crud/tariff.py index 932f8810..b88ca9ca 100644 --- a/app/database/crud/tariff.py +++ b/app/database/crud/tariff.py @@ -186,6 +186,8 @@ async def create_tariff( traffic_price_per_gb_kopeks: int = 0, min_traffic_gb: int = 1, max_traffic_gb: int = 1000, + # Режим сброса трафика + traffic_reset_mode: Optional[str] = None, # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка ) -> Tariff: """Создает новый тариф.""" normalized_prices = _normalize_period_prices(period_prices) @@ -220,6 +222,8 @@ async def create_tariff( traffic_price_per_gb_kopeks=max(0, traffic_price_per_gb_kopeks), min_traffic_gb=max(1, min_traffic_gb), max_traffic_gb=max(1, max_traffic_gb), + # Режим сброса трафика + traffic_reset_mode=traffic_reset_mode, ) db.add(tariff) @@ -283,6 +287,8 @@ async def update_tariff( traffic_price_per_gb_kopeks: Optional[int] = None, min_traffic_gb: Optional[int] = None, max_traffic_gb: Optional[int] = None, + # Режим сброса трафика + traffic_reset_mode: Optional[str] = ..., # ... = не передан, None = сбросить к глобальной настройке ) -> Tariff: """Обновляет существующий тариф.""" if name is not None: @@ -343,6 +349,9 @@ async def update_tariff( tariff.min_traffic_gb = max(1, min_traffic_gb) if max_traffic_gb is not None: tariff.max_traffic_gb = max(1, max_traffic_gb) + # Режим сброса трафика + if traffic_reset_mode is not ...: + tariff.traffic_reset_mode = traffic_reset_mode # Обновляем промогруппы если указаны if promo_group_ids is not None: diff --git a/app/database/models.py b/app/database/models.py index b5042067..3898464b 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -807,6 +807,9 @@ class Tariff(Base): min_traffic_gb = Column(Integer, default=1, nullable=False) # Минимальный трафик в ГБ max_traffic_gb = Column(Integer, default=1000, nullable=False) # Максимальный трафик в ГБ + # Режим сброса трафика: DAY, WEEK, MONTH, NO_RESET (по умолчанию берётся из конфига) + traffic_reset_mode = Column(String(20), nullable=True, default=None) # None = использовать глобальную настройку + created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 5ea3044f..c6fef6d9 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -5988,6 +5988,28 @@ async def add_tariff_custom_days_traffic_columns() -> bool: return False +async def add_tariff_traffic_reset_mode_column() -> bool: + """Добавляет колонку traffic_reset_mode в tariffs для настройки режима сброса трафика. + + Значения: DAY, WEEK, MONTH, NO_RESET (NULL = использовать глобальную настройку) + """ + try: + if not await check_column_exists('tariffs', 'traffic_reset_mode'): + async with engine.begin() as conn: + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN traffic_reset_mode VARCHAR(20) NULL" + )) + logger.info("✅ Колонка traffic_reset_mode добавлена в tariffs") + return True + else: + logger.info("ℹ️ Колонка traffic_reset_mode уже существует в tariffs") + return True + + except Exception as error: + logger.error(f"❌ Ошибка добавления колонки traffic_reset_mode: {error}") + return False + + async def add_subscription_daily_columns() -> bool: """Добавляет колонки для суточных подписок.""" try: @@ -6624,6 +6646,13 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с колонками произвольных дней/трафика в tariffs") + logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ РЕЖИМА СБРОСА ТРАФИКА В ТАРИФАХ ===") + traffic_reset_mode_ready = await add_tariff_traffic_reset_mode_column() + if traffic_reset_mode_ready: + logger.info("✅ Колонка traffic_reset_mode в tariffs готова") + else: + logger.warning("⚠️ Проблемы с колонкой traffic_reset_mode в tariffs") + logger.info("=== ДОБАВЛЕНИЕ КОЛОНОК СУТОЧНЫХ ПОДПИСОК ===") daily_subscription_columns_ready = await add_subscription_daily_columns() if daily_subscription_columns_ready: diff --git a/app/handlers/admin/tariffs.py b/app/handlers/admin/tariffs.py index 7083666f..bdaee15c 100644 --- a/app/handlers/admin/tariffs.py +++ b/app/handlers/admin/tariffs.py @@ -204,6 +204,9 @@ def get_tariff_view_keyboard( buttons.append([ InlineKeyboardButton(text="📈 Докупка трафика", callback_data=f"admin_tariff_edit_traffic_topup:{tariff.id}"), ]) + buttons.append([ + InlineKeyboardButton(text="🔄 Сброс трафика", callback_data=f"admin_tariff_edit_reset_mode:{tariff.id}"), + ]) buttons.append([ InlineKeyboardButton(text="🌐 Серверы", callback_data=f"admin_tariff_edit_squads:{tariff.id}"), InlineKeyboardButton(text="👥 Промогруппы", callback_data=f"admin_tariff_edit_promo:{tariff.id}"), @@ -250,6 +253,19 @@ def get_tariff_view_keyboard( return InlineKeyboardMarkup(inline_keyboard=buttons) +def _format_traffic_reset_mode(mode: Optional[str]) -> str: + """Форматирует режим сброса трафика для отображения.""" + mode_labels = { + 'DAY': '📅 Ежедневно', + 'WEEK': '📆 Еженедельно', + 'MONTH': '🗓️ Ежемесячно', + 'NO_RESET': '🚫 Никогда', + } + if mode is None: + return f"🌐 Глобальная настройка ({settings.DEFAULT_TRAFFIC_RESET_STRATEGY})" + return mode_labels.get(mode, f"⚠️ Неизвестно ({mode})") + + def _format_traffic_topup_packages(tariff: Tariff) -> str: """Форматирует пакеты докупки трафика для отображения.""" if not getattr(tariff, 'traffic_topup_enabled', False): @@ -312,6 +328,10 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st # Форматируем докупку трафика traffic_topup_display = _format_traffic_topup_packages(tariff) + # Форматируем режим сброса трафика + traffic_reset_mode = getattr(tariff, 'traffic_reset_mode', None) + traffic_reset_display = _format_traffic_reset_mode(traffic_reset_mode) + # Форматируем суточный тариф is_daily = getattr(tariff, 'is_daily', False) daily_price_kopeks = getattr(tariff, 'daily_price_kopeks', 0) @@ -341,6 +361,8 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st Докупка трафика: {traffic_topup_display} +Сброс трафика: {traffic_reset_display} + {price_block} Серверы: {squads_display} @@ -2595,6 +2617,124 @@ async def clear_tariff_promo_groups( pass +# ==================== Режим сброса трафика ==================== + +TRAFFIC_RESET_MODES = [ + ('DAY', '📅 Ежедневно', 'Трафик сбрасывается каждый день'), + ('WEEK', '📆 Еженедельно', 'Трафик сбрасывается каждую неделю'), + ('MONTH', '🗓️ Ежемесячно', 'Трафик сбрасывается каждый месяц'), + ('NO_RESET', '🚫 Никогда', 'Трафик не сбрасывается автоматически'), +] + + +def get_traffic_reset_mode_keyboard(tariff_id: int, current_mode: Optional[str], language: str) -> InlineKeyboardMarkup: + """Создает клавиатуру для выбора режима сброса трафика.""" + texts = get_texts(language) + buttons = [] + + # Кнопка "Глобальная настройка" + global_label = f"{'✅ ' if current_mode is None else ''}🌐 Глобальная настройка ({settings.DEFAULT_TRAFFIC_RESET_STRATEGY})" + buttons.append([ + InlineKeyboardButton( + text=global_label, + callback_data=f"admin_tariff_set_reset_mode:{tariff_id}:GLOBAL" + ) + ]) + + # Кнопки для каждого режима + for mode_value, mode_label, mode_desc in TRAFFIC_RESET_MODES: + is_selected = current_mode == mode_value + label = f"{'✅ ' if is_selected else ''}{mode_label}" + buttons.append([ + InlineKeyboardButton( + text=label, + callback_data=f"admin_tariff_set_reset_mode:{tariff_id}:{mode_value}" + ) + ]) + + # Кнопка назад + buttons.append([ + InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}") + ]) + + return InlineKeyboardMarkup(inline_keyboard=buttons) + + +@admin_required +@error_handler +async def start_edit_traffic_reset_mode( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + """Начинает редактирование режима сброса трафика.""" + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + current_mode = getattr(tariff, 'traffic_reset_mode', None) + + await callback.message.edit_text( + f"🔄 Режим сброса трафика для тарифа «{tariff.name}»\n\n" + f"Текущий режим: {_format_traffic_reset_mode(current_mode)}\n\n" + "Выберите, когда сбрасывать использованный трафик у подписчиков этого тарифа:\n\n" + "• Глобальная настройка — использовать значение из конфига бота\n" + "• Ежедневно — сброс каждый день\n" + "• Еженедельно — сброс каждую неделю\n" + "• Ежемесячно — сброс каждый месяц\n" + "• Никогда — трафик накапливается за весь период подписки", + reply_markup=get_traffic_reset_mode_keyboard(tariff_id, current_mode, db_user.language), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def set_traffic_reset_mode( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + """Устанавливает режим сброса трафика для тарифа.""" + parts = callback.data.split(":") + tariff_id = int(parts[1]) + new_mode = parts[2] + + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + # Преобразуем GLOBAL в None + if new_mode == "GLOBAL": + new_mode = None + + # Обновляем тариф + tariff = await update_tariff(db, tariff, traffic_reset_mode=new_mode) + + mode_display = _format_traffic_reset_mode(new_mode) + await callback.answer(f"Режим сброса изменён: {mode_display}", show_alert=True) + + # Обновляем клавиатуру + await callback.message.edit_text( + f"🔄 Режим сброса трафика для тарифа «{tariff.name}»\n\n" + f"Текущий режим: {mode_display}\n\n" + "Выберите, когда сбрасывать использованный трафик у подписчиков этого тарифа:\n\n" + "• Глобальная настройка — использовать значение из конфига бота\n" + "• Ежедневно — сброс каждый день\n" + "• Еженедельно — сброс каждую неделю\n" + "• Ежемесячно — сброс каждый месяц\n" + "• Никогда — трафик накапливается за весь период подписки", + reply_markup=get_traffic_reset_mode_keyboard(tariff_id, new_mode, db_user.language), + parse_mode="HTML" + ) + + def register_handlers(dp: Dispatcher): """Регистрирует обработчики для управления тарифами.""" # Список тарифов @@ -2681,3 +2821,7 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(toggle_daily_tariff, F.data.startswith("admin_tariff_toggle_daily:")) dp.callback_query.register(start_edit_daily_price, F.data.startswith("admin_tariff_edit_daily_price:")) dp.message.register(process_daily_price_input, AdminStates.editing_tariff_daily_price) + + # Режим сброса трафика + dp.callback_query.register(start_edit_traffic_reset_mode, F.data.startswith("admin_tariff_edit_reset_mode:")) + dp.callback_query.register(set_traffic_reset_mode, F.data.startswith("admin_tariff_set_reset_mode:")) diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index adac5867..fffcfc9a 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -531,7 +531,10 @@ class MonitoringService: ) result = await db.execute( select(Subscription) - .options(selectinload(Subscription.user)) + .options( + selectinload(Subscription.user), + selectinload(Subscription.tariff), + ) .where( and_( Subscription.is_trial.is_(True), diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index c4a509a6..b9938cdd 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -62,17 +62,35 @@ def _resolve_addon_discount_percent( period_days=period_days, ) -def get_traffic_reset_strategy(): +def get_traffic_reset_strategy(tariff=None): + """Получает стратегию сброса трафика. + + Args: + tariff: Объект тарифа. Если у тарифа задан traffic_reset_mode, + используется он, иначе глобальная настройка из конфига. + + Returns: + TrafficLimitStrategy: Стратегия сброса трафика для RemnaWave API. + """ from app.config import settings - strategy = settings.DEFAULT_TRAFFIC_RESET_STRATEGY.upper() - + strategy_mapping = { 'NO_RESET': 'NO_RESET', - 'DAY': 'DAY', + 'DAY': 'DAY', 'WEEK': 'WEEK', 'MONTH': 'MONTH' } - + + # Проверяем настройку тарифа + if tariff is not None: + tariff_mode = getattr(tariff, 'traffic_reset_mode', None) + if tariff_mode is not None: + mapped_strategy = strategy_mapping.get(tariff_mode.upper(), 'NO_RESET') + logger.info(f"🔄 Стратегия сброса трафика из тарифа '{getattr(tariff, 'name', 'N/A')}': {tariff_mode} -> {mapped_strategy}") + return getattr(TrafficLimitStrategy, mapped_strategy) + + # Используем глобальную настройку + strategy = settings.DEFAULT_TRAFFIC_RESET_STRATEGY.upper() mapped_strategy = strategy_mapping.get(strategy, 'NO_RESET') logger.info(f"🔄 Стратегия сброса трафика из конфига: {strategy} -> {mapped_strategy}") return getattr(TrafficLimitStrategy, mapped_strategy) @@ -205,7 +223,7 @@ class SubscriptionService: status=UserStatus.ACTIVE, expire_at=subscription.end_date, traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb), - traffic_limit_strategy=get_traffic_reset_strategy(), + traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff), description=settings.format_remnawave_user_description( full_name=user.full_name, username=user.username, @@ -242,7 +260,7 @@ class SubscriptionService: expire_at=subscription.end_date, status=UserStatus.ACTIVE, traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb), - traffic_limit_strategy=get_traffic_reset_strategy(), + traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff), telegram_id=user.telegram_id, description=settings.format_remnawave_user_description( full_name=user.full_name, @@ -326,7 +344,7 @@ class SubscriptionService: status=UserStatus.ACTIVE if is_actually_active else UserStatus.EXPIRED, expire_at=subscription.end_date, traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb), - traffic_limit_strategy=get_traffic_reset_strategy(), + traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff), description=settings.format_remnawave_user_description( full_name=user.full_name, username=user.username,