Merge pull request #2297 from BEDOLAGA-DEV/dev5

Сброс трафика на тарифах
This commit is contained in:
Egor
2026-01-16 08:51:09 +03:00
committed by GitHub
9 changed files with 233 additions and 11 deletions

View File

@@ -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)

View File

@@ -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):

View File

@@ -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()

View File

@@ -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:

View File

@@ -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())

View File

@@ -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:

View File

@@ -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
<b>Докупка трафика:</b>
{traffic_topup_display}
<b>Сброс трафика:</b> {traffic_reset_display}
{price_block}
<b>Серверы:</b> {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"🔄 <b>Режим сброса трафика для тарифа «{tariff.name}»</b>\n\n"
f"Текущий режим: {_format_traffic_reset_mode(current_mode)}\n\n"
"Выберите, когда сбрасывать использованный трафик у подписчиков этого тарифа:\n\n"
"• <b>Глобальная настройка</b> — использовать значение из конфига бота\n"
"• <b>Ежедневно</b> — сброс каждый день\n"
"• <b>Еженедельно</b> — сброс каждую неделю\n"
"• <b>Ежемесячно</b> — сброс каждый месяц\n"
"• <b>Никогда</b> — трафик накапливается за весь период подписки",
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"🔄 <b>Режим сброса трафика для тарифа «{tariff.name}»</b>\n\n"
f"Текущий режим: {mode_display}\n\n"
"Выберите, когда сбрасывать использованный трафик у подписчиков этого тарифа:\n\n"
"• <b>Глобальная настройка</b> — использовать значение из конфига бота\n"
"• <b>Ежедневно</b> — сброс каждый день\n"
"• <b>Еженедельно</b> — сброс каждую неделю\n"
"• <b>Ежемесячно</b> — сброс каждый месяц\n"
"• <b>Никогда</b> — трафик накапливается за весь период подписки",
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:"))

View File

@@ -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),

View File

@@ -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,