Merge pull request #2256 from BEDOLAGA-DEV/dev5

Dev5
This commit is contained in:
Egor
2026-01-11 03:00:42 +03:00
committed by GitHub
7 changed files with 222 additions and 2 deletions

View File

@@ -167,6 +167,9 @@ async def create_tariff(
tier_level: int = 1,
is_trial_available: bool = False,
promo_group_ids: Optional[List[int]] = None,
traffic_topup_enabled: bool = False,
traffic_topup_packages: Optional[Dict[str, int]] = None,
max_topup_traffic_gb: int = 0,
) -> Tariff:
"""Создает новый тариф."""
normalized_prices = _normalize_period_prices(period_prices)
@@ -182,6 +185,9 @@ async def create_tariff(
period_prices=normalized_prices,
tier_level=max(1, tier_level),
is_trial_available=is_trial_available,
traffic_topup_enabled=traffic_topup_enabled,
traffic_topup_packages=traffic_topup_packages or {},
max_topup_traffic_gb=max(0, max_topup_traffic_gb),
)
db.add(tariff)
@@ -229,6 +235,7 @@ async def update_tariff(
promo_group_ids: Optional[List[int]] = None,
traffic_topup_enabled: Optional[bool] = None,
traffic_topup_packages: Optional[Dict[str, int]] = None,
max_topup_traffic_gb: Optional[int] = None,
) -> Tariff:
"""Обновляет существующий тариф."""
if name is not None:
@@ -258,6 +265,8 @@ async def update_tariff(
tariff.traffic_topup_enabled = traffic_topup_enabled
if traffic_topup_packages is not None:
tariff.traffic_topup_packages = traffic_topup_packages
if max_topup_traffic_gb is not None:
tariff.max_topup_traffic_gb = max(0, max_topup_traffic_gb)
# Обновляем промогруппы если указаны
if promo_group_ids is not None:

View File

@@ -766,6 +766,8 @@ class Tariff(Base):
traffic_topup_enabled = Column(Boolean, default=False, nullable=False) # Разрешена ли докупка трафика
# Пакеты трафика: JSON {"5": 5000, "10": 9000, "20": 15000} (ГБ: цена в копейках)
traffic_topup_packages = Column(JSON, default=dict)
# Максимальный лимит трафика после докупки (0 = без ограничений)
max_topup_traffic_gb = Column(Integer, default=0, nullable=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())

View File

@@ -1402,6 +1402,7 @@ async def start_edit_tariff_traffic_topup(
is_enabled = getattr(tariff, 'traffic_topup_enabled', False)
packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {}
max_topup_traffic = getattr(tariff, 'max_topup_traffic_gb', 0) or 0
# Форматируем текущие настройки
if is_enabled:
@@ -1414,6 +1415,12 @@ async def start_edit_tariff_traffic_topup(
status = "❌ Отключено"
packages_display = " -"
# Форматируем лимит
if max_topup_traffic > 0:
max_limit_display = f"{max_topup_traffic} ГБ"
else:
max_limit_display = "Без ограничений"
buttons = []
# Переключение вкл/выкл
@@ -1426,11 +1433,14 @@ async def start_edit_tariff_traffic_topup(
InlineKeyboardButton(text="✅ Включить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}")
])
# Редактирование пакетов (только если включено)
# Редактирование пакетов и лимита (только если включено)
if is_enabled:
buttons.append([
InlineKeyboardButton(text="📦 Настроить пакеты", callback_data=f"admin_tariff_edit_topup_packages:{tariff_id}")
])
buttons.append([
InlineKeyboardButton(text="📊 Макс. лимит трафика", callback_data=f"admin_tariff_edit_max_topup:{tariff_id}")
])
buttons.append([
InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}")
@@ -1440,6 +1450,7 @@ async def start_edit_tariff_traffic_topup(
f"📈 <b>Докупка трафика для «{tariff.name}»</b>\n\n"
f"Статус: {status}\n\n"
f"<b>Пакеты:</b>\n{packages_display}\n\n"
f"<b>Макс. лимит:</b> {max_limit_display}\n\n"
"Пользователи смогут докупать трафик по заданным ценам.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
parse_mode="HTML"
@@ -1473,6 +1484,7 @@ async def toggle_tariff_traffic_topup(
# Перерисовываем меню
texts = get_texts(db_user.language)
packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {}
max_topup_traffic = getattr(tariff, 'max_topup_traffic_gb', 0) or 0
if new_value:
status = "✅ Включено"
@@ -1484,6 +1496,12 @@ async def toggle_tariff_traffic_topup(
status = "❌ Отключено"
packages_display = " -"
# Форматируем лимит
if max_topup_traffic > 0:
max_limit_display = f"{max_topup_traffic} ГБ"
else:
max_limit_display = "Без ограничений"
buttons = []
if new_value:
@@ -1493,6 +1511,9 @@ async def toggle_tariff_traffic_topup(
buttons.append([
InlineKeyboardButton(text="📦 Настроить пакеты", callback_data=f"admin_tariff_edit_topup_packages:{tariff_id}")
])
buttons.append([
InlineKeyboardButton(text="📊 Макс. лимит трафика", callback_data=f"admin_tariff_edit_max_topup:{tariff_id}")
])
else:
buttons.append([
InlineKeyboardButton(text="✅ Включить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}")
@@ -1507,6 +1528,7 @@ async def toggle_tariff_traffic_topup(
f"📈 <b>Докупка трафика для «{tariff.name}»</b>\n\n"
f"Статус: {status}\n\n"
f"<b>Пакеты:</b>\n{packages_display}\n\n"
f"<b>Макс. лимит:</b> {max_limit_display}\n\n"
"Пользователи смогут докупать трафик по заданным ценам.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
parse_mode="HTML"
@@ -1597,10 +1619,13 @@ async def process_edit_traffic_topup_packages(
# Показываем обновленное меню
texts = get_texts(db_user.language)
packages_display = "\n".join(f"{gb} ГБ: {_format_price_kopeks(price)}" for gb, price in sorted(packages.items()))
max_topup_traffic = getattr(tariff, 'max_topup_traffic_gb', 0) or 0
max_limit_display = f"{max_topup_traffic} ГБ" if max_topup_traffic > 0 else "Без ограничений"
buttons = [
[InlineKeyboardButton(text="❌ Отключить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}")],
[InlineKeyboardButton(text="📦 Настроить пакеты", callback_data=f"admin_tariff_edit_topup_packages:{tariff_id}")],
[InlineKeyboardButton(text="📊 Макс. лимит трафика", callback_data=f"admin_tariff_edit_max_topup:{tariff_id}")],
[InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}")]
]
@@ -1609,6 +1634,115 @@ async def process_edit_traffic_topup_packages(
f"📈 <b>Докупка трафика для «{tariff.name}»</b>\n\n"
f"Статус: ✅ Включено\n\n"
f"<b>Пакеты:</b>\n{packages_display}\n\n"
f"<b>Макс. лимит:</b> {max_limit_display}\n\n"
"Пользователи смогут докупать трафик по заданным ценам.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
parse_mode="HTML"
)
# ============ МАКСИМАЛЬНЫЙ ЛИМИТ ДОКУПКИ ТРАФИКА ============
@admin_required
@error_handler
async def start_edit_max_topup_traffic(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""Начинает редактирование максимального лимита докупки трафика."""
texts = get_texts(db_user.language)
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
await state.set_state(AdminStates.editing_tariff_max_topup_traffic)
await state.update_data(tariff_id=tariff_id)
current_limit = getattr(tariff, 'max_topup_traffic_gb', 0) or 0
if current_limit > 0:
current_display = f"{current_limit} ГБ"
else:
current_display = "Без ограничений"
await callback.message.edit_text(
f"📊 <b>Максимальный лимит трафика</b>\n\n"
f"Тариф: <b>{tariff.name}</b>\n"
f"Текущий лимит: <b>{current_display}</b>\n\n"
f"Введите максимальный общий объем трафика (в ГБ), который может быть на подписке после всех докупок.\n\n"
f"• Например, если тариф дает 100 ГБ и лимит 200 ГБ — пользователь сможет докупить еще 100 ГБ\n"
f"• Введите <code>0</code> для снятия ограничения",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=texts.CANCEL, callback_data=f"admin_tariff_edit_traffic_topup:{tariff_id}")]
]),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def process_edit_max_topup_traffic(
message: types.Message,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""Обрабатывает новое значение максимального лимита докупки трафика."""
texts = get_texts(db_user.language)
state_data = await state.get_data()
tariff_id = state_data.get("tariff_id")
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff:
await message.answer("Тариф не найден")
await state.clear()
return
# Парсим значение
text = message.text.strip()
try:
new_limit = int(text)
if new_limit < 0:
raise ValueError("Negative value")
except ValueError:
await message.answer(
"Введите целое число (0 или больше).\n\n"
"• <code>0</code> — без ограничений\n"
"• <code>200</code> — максимум 200 ГБ на подписке",
parse_mode="HTML"
)
return
tariff = await update_tariff(db, tariff, max_topup_traffic_gb=new_limit)
await state.clear()
# Показываем обновленное меню
packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {}
if packages:
packages_display = "\n".join(f"{gb} ГБ: {_format_price_kopeks(price)}" for gb, price in sorted(packages.items()))
else:
packages_display = " Пакеты не настроены"
max_limit_display = f"{new_limit} ГБ" if new_limit > 0 else "Без ограничений"
buttons = [
[InlineKeyboardButton(text="❌ Отключить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}")],
[InlineKeyboardButton(text="📦 Настроить пакеты", callback_data=f"admin_tariff_edit_topup_packages:{tariff_id}")],
[InlineKeyboardButton(text="📊 Макс. лимит трафика", callback_data=f"admin_tariff_edit_max_topup:{tariff_id}")],
[InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}")]
]
await message.answer(
f"✅ <b>Лимит обновлен!</b>\n\n"
f"📈 <b>Докупка трафика для «{tariff.name}»</b>\n\n"
f"Статус: ✅ Включено\n\n"
f"<b>Пакеты:</b>\n{packages_display}\n\n"
f"<b>Макс. лимит:</b> {max_limit_display}\n\n"
"Пользователи смогут докупать трафик по заданным ценам.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
parse_mode="HTML"
@@ -2166,6 +2300,10 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(start_edit_traffic_topup_packages, F.data.startswith("admin_tariff_edit_topup_packages:"))
dp.message.register(process_edit_traffic_topup_packages, AdminStates.editing_tariff_traffic_topup_packages)
# Редактирование макс. лимита докупки трафика
dp.callback_query.register(start_edit_max_topup_traffic, F.data.startswith("admin_tariff_edit_max_topup:"))
dp.message.register(process_edit_max_topup_traffic, AdminStates.editing_tariff_max_topup_traffic)
# Удаление
dp.callback_query.register(confirm_delete_tariff, F.data.startswith("admin_tariff_delete:"))
dp.callback_query.register(delete_tariff_confirmed, F.data.startswith("admin_tariff_delete_confirm:"))

View File

@@ -181,6 +181,7 @@ class AdminStates(StatesGroup):
editing_tariff_squads = State()
editing_tariff_promo_groups = State()
editing_tariff_traffic_topup_packages = State()
editing_tariff_max_topup_traffic = State()
class SupportStates(StatesGroup):

View File

@@ -3531,6 +3531,15 @@ async def _get_current_tariff_model(db: AsyncSession, subscription, user=None) -
if apply_to_addons:
traffic_discount_percent = max(0, min(100, int(getattr(promo_group, 'traffic_discount_percent', 0) or 0)))
# Лимит докупки трафика
max_topup_traffic_gb = getattr(tariff, 'max_topup_traffic_gb', 0) or 0
current_subscription_traffic = subscription.traffic_limit_gb or 0
# Рассчитываем доступный лимит докупки
available_topup_gb = None
if max_topup_traffic_gb > 0:
available_topup_gb = max(0, max_topup_traffic_gb - current_subscription_traffic)
# Пакеты докупки трафика
traffic_topup_enabled = getattr(tariff, 'traffic_topup_enabled', False) and tariff.traffic_limit_gb > 0
traffic_topup_packages = []
@@ -3538,6 +3547,10 @@ async def _get_current_tariff_model(db: AsyncSession, subscription, user=None) -
if traffic_topup_enabled and hasattr(tariff, 'get_traffic_topup_packages'):
packages = tariff.get_traffic_topup_packages()
for gb in sorted(packages.keys()):
# Фильтруем пакеты, которые превышают доступный лимит
if available_topup_gb is not None and gb > available_topup_gb:
continue
base_price = packages[gb]
# Применяем скидку
if traffic_discount_percent > 0:
@@ -3557,6 +3570,10 @@ async def _get_current_tariff_model(db: AsyncSession, subscription, user=None) -
price_label=settings.format_price(base_price),
))
# Если нет доступных пакетов из-за лимита - отключаем докупку
if traffic_topup_enabled and not traffic_topup_packages and available_topup_gb == 0:
traffic_topup_enabled = False
return MiniAppCurrentTariff(
id=tariff.id,
name=tariff.name,
@@ -3569,6 +3586,8 @@ async def _get_current_tariff_model(db: AsyncSession, subscription, user=None) -
servers_count=servers_count,
traffic_topup_enabled=traffic_topup_enabled,
traffic_topup_packages=traffic_topup_packages,
max_topup_traffic_gb=max_topup_traffic_gb,
available_topup_gb=available_topup_gb,
)
@@ -6601,6 +6620,24 @@ async def purchase_traffic_topup_endpoint(
},
)
# Проверяем лимит докупки трафика
max_topup_limit = getattr(tariff, 'max_topup_traffic_gb', 0) or 0
if max_topup_limit > 0:
current_traffic = subscription.traffic_limit_gb or 0
new_traffic = current_traffic + payload.gb
if new_traffic > max_topup_limit:
available_gb = max(0, max_topup_limit - current_traffic)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": "topup_limit_exceeded",
"message": f"Traffic top-up limit exceeded. Maximum allowed: {max_topup_limit} GB, current: {current_traffic} GB, available: {available_gb} GB",
"max_limit_gb": max_topup_limit,
"current_gb": current_traffic,
"available_gb": available_gb,
},
)
# Получаем цену пакета
packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {}
if payload.gb not in packages:

View File

@@ -551,6 +551,9 @@ class MiniAppCurrentTariff(BaseModel):
# Докупка трафика
traffic_topup_enabled: bool = False
traffic_topup_packages: List[MiniAppTrafficTopupPackage] = Field(default_factory=list)
# Лимит докупки трафика (0 = без лимита)
max_topup_traffic_gb: int = 0
available_topup_gb: Optional[int] = None # Сколько еще можно докупить (None = без лимита)
class MiniAppTrafficTopupRequest(BaseModel):

View File

@@ -6201,6 +6201,8 @@
'traffic_topup.error.generic': 'Failed to purchase traffic. Please try again.',
'traffic_topup.success.title': 'Traffic added!',
'traffic_topup.success.message': '+{gb} GB has been added to your subscription.',
'traffic_topup.available_limit': 'Available to purchase: {gb} GB',
'traffic_topup.limit_reached': 'Maximum traffic limit reached',
'button.buy_subscription': 'Buy Subscription',
'button.open_bot': 'Open Telegram bot',
'subscription_purchase.title': 'Purchase subscription',
@@ -6647,6 +6649,8 @@
'traffic_topup.error.generic': 'Не удалось докупить трафик. Попробуйте снова.',
'traffic_topup.success.title': 'Трафик добавлен!',
'traffic_topup.success.message': '+{gb} ГБ добавлено к вашей подписке.',
'traffic_topup.available_limit': 'Доступно для покупки: {gb} ГБ',
'traffic_topup.limit_reached': 'Достигнут максимальный лимит трафика',
'button.buy_subscription': 'Купить подписку',
'button.open_bot': 'Открыть бота',
'subscription_purchase.title': 'Оформление подписки',
@@ -8918,6 +8922,9 @@
window._trafficTopupPackages = trafficTopupPackages;
window._subscriptionId = userData?.subscription_id ?? userData?.subscriptionId;
window._userBalance = userData?.balance_kopeks || userData?.balanceKopeks || 0;
// Лимит докупки трафика (null = без лимита)
window._availableTopupGb = currentTariff?.available_topup_gb ?? currentTariff?.availableTopupGb ?? null;
window._maxTopupTrafficGb = currentTariff?.max_topup_traffic_gb ?? currentTariff?.maxTopupTrafficGb ?? 0;
} else {
trafficTopupBtn.classList.add('hidden');
}
@@ -12189,11 +12196,29 @@
const packages = window._trafficTopupPackages || [];
const balance = window._userBalance || 0;
const availableTopupGb = window._availableTopupGb;
packagesContainer.innerHTML = '';
// Показываем доступный лимит, если он есть
if (availableTopupGb !== null && availableTopupGb !== undefined) {
const limitEl = document.createElement('div');
limitEl.className = 'traffic-topup-limit-info';
limitEl.style.cssText = 'text-align: center; padding: 8px 12px; margin-bottom: 12px; background: var(--bg-tertiary); border-radius: 8px; font-size: 13px; color: var(--text-secondary);';
if (availableTopupGb <= 0) {
limitEl.textContent = t('traffic_topup.limit_reached');
limitEl.style.color = 'var(--danger-color)';
} else {
limitEl.textContent = t('traffic_topup.available_limit').replace('{gb}', availableTopupGb);
}
packagesContainer.appendChild(limitEl);
}
if (packages.length === 0) {
packagesContainer.innerHTML = '<div class="traffic-topup-empty">' + t('traffic_topup.empty') + '</div>';
const emptyEl = document.createElement('div');
emptyEl.className = 'traffic-topup-empty';
emptyEl.textContent = t('traffic_topup.empty');
packagesContainer.appendChild(emptyEl);
return;
}
@@ -12303,6 +12328,11 @@
trafficLimitEl.textContent = formatTrafficLimit(data.new_traffic_limit_gb);
}
// Обновляем доступный лимит докупки
if (window._availableTopupGb !== null && window._availableTopupGb !== undefined) {
window._availableTopupGb = Math.max(0, window._availableTopupGb - gb);
}
// Обновляем данные через 2 секунды
setTimeout(() => {
refreshSubscriptionData({ silent: true });