rate configurator/ purchase of conditions/ fixed payments/ refactoring/ major improvements and fixes
This commit is contained in:
+6
-3
@@ -1,10 +1,12 @@
|
||||
from database import async_session_maker
|
||||
|
||||
from .settings.buttons_config import BUTTONS_CONFIG, load_buttons_config, update_buttons_config
|
||||
from .settings.notifications_config import NOTIFICATIONS_CONFIG, load_notifications_config, update_notifications_config
|
||||
from .settings.management_config import MANAGEMENT_CONFIG, load_management_config, update_management_config
|
||||
from .settings.modes_config import MODES_CONFIG, load_modes_config, update_modes_config
|
||||
from .settings.payments_config import PAYMENTS_CONFIG, load_payments_config, update_payments_config
|
||||
from .settings.money_config import MONEY_CONFIG, load_money_config, update_money_config
|
||||
from .settings.management_config import MANAGEGENT_CONFIG, load_management_config, update_management_config
|
||||
from .settings.notifications_config import NOTIFICATIONS_CONFIG, load_notifications_config, update_notifications_config
|
||||
from .settings.payments_config import PAYMENTS_CONFIG, load_payments_config, update_payments_config
|
||||
from .settings.tariffs_config import TARIFFS_CONFIG, load_tariffs_config, update_tariffs_config
|
||||
|
||||
|
||||
async def bootstrap() -> None:
|
||||
@@ -15,4 +17,5 @@ async def bootstrap() -> None:
|
||||
await load_payments_config(session)
|
||||
await load_money_config(session)
|
||||
await load_management_config(session)
|
||||
await load_tariffs_config(session)
|
||||
await session.commit()
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
PAYMENT_SYSTEMS_EXCLUDED = ("referral", "coupon", "cashback", "admin")
|
||||
PAYMENT_SYSTEMS_EXCLUDED = ("referral", "coupon", "cashback", "admin")
|
||||
|
||||
@@ -2,10 +2,12 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Setting
|
||||
|
||||
from ..defaults import DEFAULT_BUTTONS_CONFIG
|
||||
|
||||
|
||||
BUTTONS_CONFIG: dict[str, bool] = DEFAULT_BUTTONS_CONFIG.copy()
|
||||
BUTTONS_CONFIG.setdefault("ANDROID_TV_BUTTON_ENABLE", False)
|
||||
|
||||
|
||||
async def load_buttons_config(session: AsyncSession) -> None:
|
||||
@@ -15,6 +17,7 @@ async def load_buttons_config(session: AsyncSession) -> None:
|
||||
|
||||
if setting is None:
|
||||
buttons_config = DEFAULT_BUTTONS_CONFIG.copy()
|
||||
buttons_config.setdefault("ANDROID_TV_BUTTON_ENABLE", False)
|
||||
setting = Setting(
|
||||
key="BUTTONS_CONFIG",
|
||||
value=buttons_config,
|
||||
@@ -25,6 +28,7 @@ async def load_buttons_config(session: AsyncSession) -> None:
|
||||
stored = setting.value or {}
|
||||
buttons_config = DEFAULT_BUTTONS_CONFIG.copy()
|
||||
buttons_config.update(stored)
|
||||
buttons_config.setdefault("ANDROID_TV_BUTTON_ENABLE", False)
|
||||
setting.value = buttons_config
|
||||
|
||||
BUTTONS_CONFIG.clear()
|
||||
@@ -47,10 +51,11 @@ async def update_buttons_config(session: AsyncSession, new_values: dict[str, boo
|
||||
else:
|
||||
setting.value = new_values
|
||||
|
||||
await session.flush()
|
||||
await session.commit()
|
||||
|
||||
buttons_config = DEFAULT_BUTTONS_CONFIG.copy()
|
||||
buttons_config.update(new_values)
|
||||
buttons_config.setdefault("ANDROID_TV_BUTTON_ENABLE", False)
|
||||
|
||||
BUTTONS_CONFIG.clear()
|
||||
BUTTONS_CONFIG.update(buttons_config)
|
||||
|
||||
@@ -4,21 +4,23 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Setting
|
||||
|
||||
from ..defaults import DEFAULT_MANAGEMENT_CONFIG
|
||||
|
||||
|
||||
MANAGEGENT_CONFIG: dict[str, Any] = DEFAULT_MANAGEMENT_CONFIG.copy()
|
||||
MANAGEMENT_CONFIG: dict[str, Any] = DEFAULT_MANAGEMENT_CONFIG.copy()
|
||||
MANAGEMENT_SETTING_KEY = "MANAGEGENT_CONFIG"
|
||||
|
||||
|
||||
async def load_management_config(session: AsyncSession) -> None:
|
||||
stmt = select(Setting).where(Setting.key == "management")
|
||||
stmt = select(Setting).where(Setting.key == MANAGEMENT_SETTING_KEY)
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
management_config = DEFAULT_MANAGEMENT_CONFIG.copy()
|
||||
setting = Setting(
|
||||
key="management",
|
||||
key=MANAGEMENT_SETTING_KEY,
|
||||
value=management_config,
|
||||
description="Конфигурация управления ботом",
|
||||
)
|
||||
@@ -29,19 +31,19 @@ async def load_management_config(session: AsyncSession) -> None:
|
||||
management_config.update(stored)
|
||||
setting.value = management_config
|
||||
|
||||
MANAGEGENT_CONFIG.clear()
|
||||
MANAGEGENT_CONFIG.update(management_config)
|
||||
MANAGEMENT_CONFIG.clear()
|
||||
MANAGEMENT_CONFIG.update(management_config)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def update_management_config(session: AsyncSession, new_values: dict[str, Any]) -> None:
|
||||
stmt = select(Setting).where(Setting.key == "management")
|
||||
stmt = select(Setting).where(Setting.key == MANAGEMENT_SETTING_KEY)
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
setting = Setting(
|
||||
key="management",
|
||||
key=MANAGEMENT_SETTING_KEY,
|
||||
value=new_values,
|
||||
description="Конфигурация управления ботом",
|
||||
)
|
||||
@@ -49,11 +51,10 @@ async def update_management_config(session: AsyncSession, new_values: dict[str,
|
||||
else:
|
||||
setting.value = new_values
|
||||
|
||||
await session.flush()
|
||||
await session.commit()
|
||||
|
||||
management_config = DEFAULT_MANAGEMENT_CONFIG.copy()
|
||||
management_config.update(new_values)
|
||||
|
||||
MANAGEGENT_CONFIG.clear()
|
||||
MANAGEGENT_CONFIG.update(management_config)
|
||||
MANAGEMENT_CONFIG.clear()
|
||||
MANAGEMENT_CONFIG.update(management_config)
|
||||
|
||||
@@ -2,6 +2,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Setting
|
||||
|
||||
from ..defaults import DEFAULT_MODES_CONFIG
|
||||
|
||||
|
||||
@@ -47,7 +48,7 @@ async def update_modes_config(session: AsyncSession, new_values: dict[str, bool]
|
||||
else:
|
||||
setting.value = new_values
|
||||
|
||||
await session.flush()
|
||||
await session.commit()
|
||||
|
||||
modes_config = DEFAULT_MODES_CONFIG.copy()
|
||||
modes_config.update(new_values)
|
||||
|
||||
@@ -4,6 +4,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Setting
|
||||
|
||||
from ..defaults import DEFAULT_MONEY_CONFIG
|
||||
|
||||
|
||||
@@ -49,7 +50,7 @@ async def update_money_config(session: AsyncSession, new_values: dict[str, Any])
|
||||
else:
|
||||
setting.value = new_values
|
||||
|
||||
await session.flush()
|
||||
await session.commit()
|
||||
|
||||
money_config = DEFAULT_MONEY_CONFIG.copy()
|
||||
money_config.update(new_values)
|
||||
|
||||
@@ -4,6 +4,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Setting
|
||||
|
||||
from ..defaults import DEFAULT_NOTIFICATIONS_CONFIG
|
||||
|
||||
|
||||
@@ -49,7 +50,7 @@ async def update_notifications_config(session: AsyncSession, new_values: dict[st
|
||||
else:
|
||||
setting.value = new_values
|
||||
|
||||
await session.flush()
|
||||
await session.commit()
|
||||
|
||||
notifications_config = DEFAULT_NOTIFICATIONS_CONFIG.copy()
|
||||
notifications_config.update(new_values)
|
||||
|
||||
@@ -2,6 +2,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Setting
|
||||
|
||||
from ..defaults import DEFAULT_PAYMENTS_CONFIG
|
||||
|
||||
|
||||
@@ -47,7 +48,7 @@ async def update_payments_config(session: AsyncSession, new_values: dict[str, bo
|
||||
else:
|
||||
setting.value = new_values
|
||||
|
||||
await session.flush()
|
||||
await session.commit()
|
||||
|
||||
payments_config = DEFAULT_PAYMENTS_CONFIG.copy()
|
||||
payments_config.update(new_values)
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
from math import ceil
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Setting
|
||||
|
||||
|
||||
TARIFFS_CONFIG: dict[str, Any] = {
|
||||
"ALLOW_DOWNGRADE": True,
|
||||
"KEY_ADDONS_PACK_MODE": "all",
|
||||
}
|
||||
|
||||
|
||||
async def load_tariffs_config(session: AsyncSession) -> None:
|
||||
"""Загружает конфиг тарифов из БД."""
|
||||
stmt = select(Setting).where(Setting.key == "TARIFFS_CONFIG")
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
tariffs_config = TARIFFS_CONFIG.copy()
|
||||
setting = Setting(
|
||||
key="TARIFFS_CONFIG",
|
||||
value=tariffs_config,
|
||||
description="Конфигурация тарифов",
|
||||
)
|
||||
session.add(setting)
|
||||
else:
|
||||
stored = setting.value or {}
|
||||
tariffs_config = TARIFFS_CONFIG.copy()
|
||||
tariffs_config.update(stored)
|
||||
setting.value = tariffs_config
|
||||
|
||||
TARIFFS_CONFIG.clear()
|
||||
TARIFFS_CONFIG.update(tariffs_config)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def update_tariffs_config(session: AsyncSession, new_values: dict[str, Any]) -> None:
|
||||
"""Обновляет конфиг тарифов."""
|
||||
stmt = select(Setting).where(Setting.key == "TARIFFS_CONFIG")
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
setting = Setting(
|
||||
key="TARIFFS_CONFIG",
|
||||
value=new_values,
|
||||
description="Конфигурация тарифов",
|
||||
)
|
||||
session.add(setting)
|
||||
else:
|
||||
setting.value = new_values
|
||||
|
||||
await session.commit()
|
||||
|
||||
tariffs_config = TARIFFS_CONFIG.copy()
|
||||
tariffs_config.update(new_values)
|
||||
|
||||
TARIFFS_CONFIG.clear()
|
||||
TARIFFS_CONFIG.update(tariffs_config)
|
||||
|
||||
|
||||
def calc_extra_devices_price(tariff: dict[str, Any], device_limit: int) -> int:
|
||||
base_device_limit = int(tariff.get("device_limit") or 1)
|
||||
extra_devices = max(0, device_limit - base_device_limit)
|
||||
if extra_devices <= 0:
|
||||
return 0
|
||||
|
||||
step_price = int(tariff.get("device_step_rub") or 0)
|
||||
overrides = tariff.get("device_overrides") or {}
|
||||
|
||||
override_total = overrides.get(str(device_limit))
|
||||
if override_total is not None:
|
||||
return int(ceil(float(override_total)))
|
||||
|
||||
return int(ceil(extra_devices * step_price))
|
||||
|
||||
|
||||
def calc_extra_traffic_price(tariff: dict[str, Any], traffic_gb: int | None) -> int:
|
||||
if traffic_gb is None:
|
||||
return 0
|
||||
|
||||
traffic_limit_bytes = tariff.get("traffic_limit")
|
||||
if traffic_limit_bytes:
|
||||
base_traffic_gb = ceil(traffic_limit_bytes / 1024 / 1024 / 1024)
|
||||
else:
|
||||
base_traffic_gb = 0
|
||||
|
||||
step_price = int(tariff.get("traffic_step_rub") or 0)
|
||||
overrides = tariff.get("traffic_overrides") or {}
|
||||
|
||||
override_total = overrides.get(str(traffic_gb))
|
||||
if override_total is not None:
|
||||
return int(ceil(float(override_total)))
|
||||
|
||||
if traffic_gb == 0:
|
||||
return 0
|
||||
|
||||
extra_gb = max(0, traffic_gb - base_traffic_gb)
|
||||
if extra_gb <= 0:
|
||||
return 0
|
||||
|
||||
return int(ceil(extra_gb * step_price))
|
||||
|
||||
|
||||
def calculate_config_price(
|
||||
tariff: dict[str, Any],
|
||||
duration_days: int,
|
||||
device_limit: int,
|
||||
traffic_gb: int | None,
|
||||
) -> int:
|
||||
base_duration = int(tariff.get("duration_days") or 0) or duration_days or 30
|
||||
if base_duration <= 0:
|
||||
base_duration = duration_days or 30
|
||||
|
||||
base_price = int(tariff.get("price_rub") or 0)
|
||||
|
||||
duration_multiplier = duration_days / base_duration
|
||||
base_price_scaled = base_price * duration_multiplier
|
||||
|
||||
extra_devices_price = calc_extra_devices_price(tariff, device_limit)
|
||||
extra_traffic_price = calc_extra_traffic_price(tariff, traffic_gb)
|
||||
|
||||
total = base_price_scaled + extra_devices_price + extra_traffic_price
|
||||
return int(ceil(total))
|
||||
|
||||
|
||||
def normalize_tariff_config(tariff: dict[str, Any]) -> dict[str, Any]:
|
||||
raw_duration_options = tariff.get("duration_options") or []
|
||||
duration_options: list[int] = []
|
||||
for value in raw_duration_options:
|
||||
try:
|
||||
v = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if v > 0:
|
||||
duration_options.append(v)
|
||||
if not duration_options:
|
||||
base_duration = int(tariff.get("duration_days") or 0) or 30
|
||||
duration_options = [base_duration]
|
||||
duration_options = sorted(set(duration_options))
|
||||
|
||||
raw_device_options = tariff.get("device_options") or []
|
||||
device_options: list[int] = []
|
||||
for value in raw_device_options:
|
||||
try:
|
||||
v = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if v > 0:
|
||||
device_options.append(v)
|
||||
if not device_options:
|
||||
base_device_limit = int(tariff.get("device_limit") or 0)
|
||||
if base_device_limit > 0:
|
||||
device_options = [base_device_limit]
|
||||
else:
|
||||
device_options = []
|
||||
device_options = sorted(set(device_options))
|
||||
|
||||
raw_traffic_options = tariff.get("traffic_options_gb")
|
||||
traffic_options_gb: list[int] | None
|
||||
if raw_traffic_options is None:
|
||||
traffic_options_gb = None
|
||||
else:
|
||||
traffic_values: list[int] = []
|
||||
has_unlimited = False
|
||||
for value in raw_traffic_options:
|
||||
try:
|
||||
v = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if v == 0:
|
||||
has_unlimited = True
|
||||
elif v > 0:
|
||||
traffic_values.append(v)
|
||||
if not traffic_values and not has_unlimited:
|
||||
traffic_options_gb = None
|
||||
else:
|
||||
unique_values = sorted(set(traffic_values))
|
||||
if has_unlimited:
|
||||
traffic_options_gb = [0] + unique_values
|
||||
else:
|
||||
traffic_options_gb = unique_values
|
||||
|
||||
return {
|
||||
"duration_options": duration_options,
|
||||
"device_options": device_options,
|
||||
"traffic_options_gb": traffic_options_gb,
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Key, Payment, User
|
||||
from core.constants import PAYMENT_SYSTEMS_EXCLUDED
|
||||
from database.models import Key, Payment, User
|
||||
|
||||
|
||||
async def get_hot_leads(session: AsyncSession):
|
||||
|
||||
+104
-18
@@ -17,29 +17,49 @@ async def store_key(
|
||||
key: str,
|
||||
server_id: str,
|
||||
remnawave_link: str = None,
|
||||
tariff_id: int = None,
|
||||
alias: str = None,
|
||||
tariff_id: int | None = None,
|
||||
alias: str | None = None,
|
||||
selected_device_limit: int | None = None,
|
||||
selected_traffic_limit: int | None = None,
|
||||
selected_price_rub: int | None = None,
|
||||
current_device_limit: int | None = None,
|
||||
current_traffic_limit: int | None = None,
|
||||
):
|
||||
"""Сохраняет или обновляет ключ подписки."""
|
||||
try:
|
||||
exists = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.client_id == client_id))
|
||||
existing_key = exists.scalar_one_or_none()
|
||||
|
||||
if existing_key:
|
||||
await session.execute(
|
||||
update(Key)
|
||||
.where(Key.tg_id == tg_id, Key.client_id == client_id)
|
||||
.values(
|
||||
email=email,
|
||||
expiry_time=expiry_time,
|
||||
key=key,
|
||||
server_id=server_id,
|
||||
remnawave_link=remnawave_link,
|
||||
tariff_id=tariff_id,
|
||||
alias=alias,
|
||||
)
|
||||
)
|
||||
values: dict = {
|
||||
"email": email,
|
||||
"expiry_time": expiry_time,
|
||||
"key": key,
|
||||
"server_id": server_id,
|
||||
"remnawave_link": remnawave_link,
|
||||
"tariff_id": tariff_id,
|
||||
"alias": alias,
|
||||
}
|
||||
|
||||
if selected_device_limit is not None:
|
||||
values["selected_device_limit"] = selected_device_limit
|
||||
if selected_traffic_limit is not None:
|
||||
values["selected_traffic_limit"] = selected_traffic_limit
|
||||
if selected_price_rub is not None:
|
||||
values["selected_price_rub"] = selected_price_rub
|
||||
if current_device_limit is not None:
|
||||
values["current_device_limit"] = current_device_limit
|
||||
if current_traffic_limit is not None:
|
||||
values["current_traffic_limit"] = current_traffic_limit
|
||||
|
||||
await session.execute(update(Key).where(Key.tg_id == tg_id, Key.client_id == client_id).values(**values))
|
||||
logger.info(f"[Store Key] Ключ обновлён: tg_id={tg_id}, client_id={client_id}, server_id={server_id}")
|
||||
else:
|
||||
if current_device_limit is None:
|
||||
current_device_limit = selected_device_limit
|
||||
if current_traffic_limit is None:
|
||||
current_traffic_limit = selected_traffic_limit
|
||||
|
||||
new_key = Key(
|
||||
tg_id=tg_id,
|
||||
client_id=client_id,
|
||||
@@ -51,6 +71,11 @@ async def store_key(
|
||||
remnawave_link=remnawave_link,
|
||||
tariff_id=tariff_id,
|
||||
alias=alias,
|
||||
selected_device_limit=selected_device_limit,
|
||||
selected_traffic_limit=selected_traffic_limit,
|
||||
selected_price_rub=selected_price_rub,
|
||||
current_device_limit=current_device_limit,
|
||||
current_traffic_limit=current_traffic_limit,
|
||||
)
|
||||
session.add(new_key)
|
||||
logger.info(f"[Store Key] Ключ создан: tg_id={tg_id}, client_id={client_id}, server_id={server_id}")
|
||||
@@ -79,6 +104,7 @@ async def get_key_by_server(session: AsyncSession, tg_id: int, client_id: str):
|
||||
|
||||
|
||||
async def get_key_details(session: AsyncSession, email: str) -> dict | None:
|
||||
"""Возвращает подробную информацию о ключе по email."""
|
||||
stmt = select(Key, User).join(User, Key.tg_id == User.tg_id).where(Key.email == email)
|
||||
result = await session.execute(stmt)
|
||||
row = result.first()
|
||||
@@ -116,6 +142,11 @@ async def get_key_details(session: AsyncSession, email: str) -> dict | None:
|
||||
"cluster_name": key.server_id,
|
||||
"location_name": key.server_id,
|
||||
"tariff_id": key.tariff_id,
|
||||
"selected_device_limit": key.selected_device_limit,
|
||||
"selected_traffic_limit": key.selected_traffic_limit,
|
||||
"selected_price_rub": key.selected_price_rub,
|
||||
"current_device_limit": key.current_device_limit,
|
||||
"current_traffic_limit": key.current_traffic_limit,
|
||||
}
|
||||
|
||||
|
||||
@@ -156,13 +187,18 @@ async def mark_key_as_frozen(session: AsyncSession, tg_id: int, client_id: str,
|
||||
is_frozen = TRUE
|
||||
WHERE tg_id = :tg_id
|
||||
AND client_id = :client_id
|
||||
"""
|
||||
"""
|
||||
),
|
||||
{"expiry": time_left, "tg_id": tg_id, "client_id": client_id},
|
||||
)
|
||||
|
||||
|
||||
async def mark_key_as_unfrozen(session: AsyncSession, tg_id: int, client_id: str, new_expiry_time: int):
|
||||
async def mark_key_as_unfrozen(
|
||||
session: AsyncSession,
|
||||
tg_id: int,
|
||||
client_id: str,
|
||||
new_expiry_time: int,
|
||||
):
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
@@ -171,7 +207,7 @@ async def mark_key_as_unfrozen(session: AsyncSession, tg_id: int, client_id: str
|
||||
is_frozen = FALSE
|
||||
WHERE tg_id = :tg_id
|
||||
AND client_id = :client_id
|
||||
"""
|
||||
"""
|
||||
),
|
||||
{"expiry": new_expiry_time, "tg_id": tg_id, "client_id": client_id},
|
||||
)
|
||||
@@ -199,3 +235,53 @@ async def update_key_link(session: AsyncSession, email: str, link: str) -> bool:
|
||||
res = await session.execute(q)
|
||||
await session.commit()
|
||||
return res.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def save_key_config_with_mode(
|
||||
session: AsyncSession,
|
||||
email: str,
|
||||
selected_devices: int | None,
|
||||
selected_traffic_gb: int | None,
|
||||
total_price: int,
|
||||
has_device_choice: bool,
|
||||
has_traffic_choice: bool,
|
||||
config_mode: str,
|
||||
) -> None:
|
||||
values: dict = {}
|
||||
|
||||
if config_mode == "pack":
|
||||
if has_device_choice and selected_devices is not None:
|
||||
values["current_device_limit"] = int(selected_devices)
|
||||
if has_traffic_choice and selected_traffic_gb is not None:
|
||||
values["current_traffic_limit"] = int(selected_traffic_gb)
|
||||
else:
|
||||
device_val = int(selected_devices) if selected_devices is not None and has_device_choice else None
|
||||
traffic_val = int(selected_traffic_gb) if selected_traffic_gb is not None and has_traffic_choice else None
|
||||
|
||||
values["selected_device_limit"] = device_val
|
||||
values["selected_traffic_limit"] = traffic_val
|
||||
values["selected_price_rub"] = int(total_price)
|
||||
values["current_device_limit"] = device_val
|
||||
values["current_traffic_limit"] = traffic_val
|
||||
|
||||
if not values:
|
||||
return
|
||||
|
||||
await session.execute(update(Key).where(Key.email == email).values(**values))
|
||||
|
||||
|
||||
async def reset_key_current_limits_to_selected(session: AsyncSession, client_id: str):
|
||||
"""Сбрасывает текущие лимиты к выбранным для ключа."""
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE keys
|
||||
SET current_device_limit = selected_device_limit,
|
||||
current_traffic_limit = selected_traffic_limit
|
||||
WHERE client_id = :client_id
|
||||
"""
|
||||
),
|
||||
{"client_id": client_id},
|
||||
)
|
||||
await session.commit()
|
||||
logger.info(f"Текущие лимиты ключа {client_id} сброшены к выбранным")
|
||||
|
||||
@@ -77,6 +77,13 @@ class Key(DictLikeMixin, Base):
|
||||
notified = Column(Boolean, default=False)
|
||||
notified_24h = Column(Boolean, default=False)
|
||||
|
||||
selected_device_limit = Column(Integer, nullable=True)
|
||||
selected_traffic_limit = Column(BigInteger, nullable=True)
|
||||
selected_price_rub = Column(Integer, nullable=True)
|
||||
|
||||
current_device_limit = Column(Integer, nullable=True)
|
||||
current_traffic_limit = Column(BigInteger, nullable=True)
|
||||
|
||||
|
||||
class Tariff(DictLikeMixin, Base):
|
||||
__tablename__ = "tariffs"
|
||||
@@ -95,6 +102,17 @@ class Tariff(DictLikeMixin, Base):
|
||||
sort_order = Column(Integer, nullable=True)
|
||||
vless = Column(Boolean, default=False)
|
||||
|
||||
configurable = Column(Boolean, nullable=False, server_default="false")
|
||||
|
||||
device_options = Column(JSONB, nullable=True)
|
||||
traffic_options_gb = Column(JSONB, nullable=True)
|
||||
|
||||
device_step_rub = Column(Integer, nullable=True)
|
||||
device_overrides = Column(JSONB, nullable=True)
|
||||
|
||||
traffic_step_rub = Column(Integer, nullable=True)
|
||||
traffic_overrides = Column(JSONB, nullable=True)
|
||||
|
||||
|
||||
class Server(DictLikeMixin, Base):
|
||||
__tablename__ = "servers"
|
||||
|
||||
@@ -3,6 +3,7 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import CHECK_REFERRAL_REWARD_ISSUED, REFERRAL_BONUS_PERCENTAGES
|
||||
from core.bootstrap import BUTTONS_CONFIG
|
||||
from database.models import Referral
|
||||
from logger import logger
|
||||
|
||||
@@ -57,6 +58,11 @@ async def mark_referral_reward_issued(session: AsyncSession, referred_tg_id: int
|
||||
|
||||
|
||||
async def get_total_referral_bonus(session: AsyncSession, referrer_tg_id: int, max_levels: int) -> float:
|
||||
referral_enabled = bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", True))
|
||||
if not referral_enabled:
|
||||
logger.debug("Реферальная программа отключена, бонусы не начисляются")
|
||||
return 0.0
|
||||
|
||||
if CHECK_REFERRAL_REWARD_ISSUED:
|
||||
bonus_cte = """
|
||||
WITH RECURSIVE
|
||||
|
||||
@@ -3,8 +3,8 @@ from datetime import date, datetime
|
||||
from sqlalchemy import and_, exists, func, not_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Key, Payment, Referral, Tariff, User
|
||||
from core.constants import PAYMENT_SYSTEMS_EXCLUDED
|
||||
from database.models import Key, Payment, Referral, Tariff, User
|
||||
|
||||
|
||||
async def count_total_users(session: AsyncSession) -> int:
|
||||
|
||||
@@ -2,9 +2,9 @@ from sqlalchemy import and_, func, insert, not_, select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.constants import PAYMENT_SYSTEMS_EXCLUDED
|
||||
from database.models import Payment, TrackingSource, User
|
||||
from logger import logger
|
||||
from core.constants import PAYMENT_SYSTEMS_EXCLUDED
|
||||
|
||||
|
||||
async def create_tracking_source(session: AsyncSession, name: str, code: str, type_: str, created_by: int):
|
||||
|
||||
@@ -13,6 +13,7 @@ from .payments import router as payments_router
|
||||
from .profile import router as profile_router
|
||||
from .refferal import router as refferal_router
|
||||
from .start import router as start_router
|
||||
from .tariffs import router as tariff_router
|
||||
|
||||
|
||||
router = Router(name="handlers_main_router")
|
||||
@@ -29,4 +30,5 @@ router.include_routers(
|
||||
instructions_router,
|
||||
admin_router,
|
||||
refferal_router,
|
||||
tariff_router,
|
||||
)
|
||||
|
||||
@@ -59,11 +59,7 @@ async def apply_tariff_group(callback: CallbackQuery, callback_data: AdminCluste
|
||||
|
||||
group_code = row["group_code"]
|
||||
|
||||
await session.execute(
|
||||
update(Server)
|
||||
.where(Server.cluster_name == cluster_name)
|
||||
.values(tariff_group=group_code)
|
||||
)
|
||||
await session.execute(update(Server).where(Server.cluster_name == cluster_name).values(tariff_group=group_code))
|
||||
await session.commit()
|
||||
|
||||
servers = await get_servers(session=session, include_enabled=True)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from core.bootstrap import MANAGEGENT_CONFIG
|
||||
from core.bootstrap import MANAGEMENT_CONFIG
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
|
||||
|
||||
|
||||
@@ -39,7 +40,7 @@ def build_management_kb(admin_role: str) -> InlineKeyboardMarkup:
|
||||
callback_data=AdminPanelCallback(action="upload_file").pack(),
|
||||
)
|
||||
|
||||
maintenance_enabled = bool(MANAGEGENT_CONFIG.get("MAINTENANCE_ENABLED", False))
|
||||
maintenance_enabled = bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False))
|
||||
maintenance_text = "🛠️ Выключить тех. работы" if maintenance_enabled else "🛠️ Включить тех. работы"
|
||||
builder.button(
|
||||
text=maintenance_text,
|
||||
|
||||
@@ -3,7 +3,7 @@ from aiogram.types import CallbackQuery
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.bootstrap import MANAGEGENT_CONFIG, update_management_config
|
||||
from core.bootstrap import MANAGEMENT_CONFIG, update_management_config
|
||||
from database.models import Admin
|
||||
from filters.admin import IsAdminFilter
|
||||
|
||||
@@ -39,7 +39,7 @@ async def toggle_maintenance_mode(callback: CallbackQuery, session: AsyncSession
|
||||
await callback.answer("❌ Админ не найден.", show_alert=True)
|
||||
return
|
||||
|
||||
current_config = dict(MANAGEGENT_CONFIG)
|
||||
current_config = dict(MANAGEMENT_CONFIG)
|
||||
current_value = bool(current_config.get("MAINTENANCE_ENABLED", False))
|
||||
new_value = not current_value
|
||||
current_config["MAINTENANCE_ENABLED"] = new_value
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from .sender_handler import router
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
@@ -11,8 +11,8 @@ from logger import logger
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
|
||||
from .keyboard import AdminSenderCallback, build_clusters_kb, build_sender_kb
|
||||
from .sender_states import AdminSender
|
||||
from .sender_service import BroadcastService
|
||||
from .sender_states import AdminSender
|
||||
from .sender_utils import get_recipients, parse_message_buttons
|
||||
|
||||
|
||||
@@ -54,11 +54,7 @@ async def handle_cluster_select(callback_query: CallbackQuery, session: AsyncSes
|
||||
AdminSenderCallback.filter(F.type != "cluster-select"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_broadcast_type(
|
||||
callback_query: CallbackQuery,
|
||||
callback_data: AdminSenderCallback,
|
||||
state: FSMContext
|
||||
):
|
||||
async def handle_broadcast_type(callback_query: CallbackQuery, callback_data: AdminSenderCallback, state: FSMContext):
|
||||
await callback_query.message.edit_text(
|
||||
text=(
|
||||
"✍️ Введите текст сообщения для рассылки\n\n"
|
||||
@@ -92,8 +88,7 @@ async def handle_message_input(message: Message, state: FSMContext, session: Asy
|
||||
max_len = 1024 if photo else 4096
|
||||
if len(clean_text) > max_len:
|
||||
await message.answer(
|
||||
f"⚠️ Сообщение слишком длинное.\n"
|
||||
f"Максимум: <b>{max_len}</b> символов, сейчас: <b>{len(clean_text)}</b>.",
|
||||
f"⚠️ Сообщение слишком длинное.\nМаксимум: <b>{max_len}</b> символов, сейчас: <b>{len(clean_text)}</b>.",
|
||||
reply_markup=build_admin_back_kb("sender"),
|
||||
)
|
||||
await state.clear()
|
||||
@@ -119,42 +114,21 @@ async def handle_message_input(message: Message, state: FSMContext, session: Asy
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await state.update_data(
|
||||
text=clean_text,
|
||||
photo=photo,
|
||||
keyboard=keyboard.model_dump() if keyboard else None
|
||||
)
|
||||
await state.update_data(text=clean_text, photo=photo, keyboard=keyboard.model_dump() if keyboard else None)
|
||||
await state.set_state(AdminSender.preview)
|
||||
|
||||
if photo:
|
||||
await message.answer_photo(
|
||||
photo=photo,
|
||||
caption=clean_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
await message.answer_photo(photo=photo, caption=clean_text, parse_mode="HTML", reply_markup=keyboard)
|
||||
else:
|
||||
await message.answer(
|
||||
text=clean_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
await message.answer(text=clean_text, parse_mode="HTML", reply_markup=keyboard)
|
||||
|
||||
await message.answer(
|
||||
f"👀 Это предпросмотр рассылки.\n"
|
||||
f"👥 Количество получателей: <b>{user_count}</b>\n\n"
|
||||
f"Отправить?",
|
||||
f"👀 Это предпросмотр рассылки.\n👥 Количество получателей: <b>{user_count}</b>\n\nОтправить?",
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📤 Отправить",
|
||||
callback_data="send_broadcast"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="❌ Отмена",
|
||||
callback_data="cancel_broadcast"
|
||||
),
|
||||
InlineKeyboardButton(text="📤 Отправить", callback_data="send_broadcast"),
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_broadcast"),
|
||||
]
|
||||
]
|
||||
),
|
||||
@@ -162,11 +136,7 @@ async def handle_message_input(message: Message, state: FSMContext, session: Asy
|
||||
|
||||
|
||||
@router.callback_query(F.data == "send_broadcast", IsAdminFilter())
|
||||
async def handle_broadcast_confirm(
|
||||
callback_query: CallbackQuery,
|
||||
state: FSMContext,
|
||||
session: AsyncSession
|
||||
):
|
||||
async def handle_broadcast_confirm(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
data = await state.get_data()
|
||||
text_message = data.get("text")
|
||||
photo = data.get("photo")
|
||||
@@ -200,35 +170,21 @@ async def handle_broadcast_confirm(
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
f"📤 <b>Рассылка начата!</b>\n"
|
||||
f"👥 Количество получателей: {total_users}"
|
||||
)
|
||||
await callback_query.message.edit_text(f"📤 <b>Рассылка начата!</b>\n👥 Количество получателей: {total_users}")
|
||||
|
||||
messages = []
|
||||
for tg_id in tg_ids:
|
||||
message_data = {
|
||||
"tg_id": tg_id,
|
||||
"text": text_message,
|
||||
"photo": photo,
|
||||
"keyboard": keyboard
|
||||
}
|
||||
message_data = {"tg_id": tg_id, "text": text_message, "photo": photo, "keyboard": keyboard}
|
||||
messages.append(message_data)
|
||||
|
||||
broadcast_service = BroadcastService(
|
||||
bot=callback_query.bot,
|
||||
session=session,
|
||||
messages_per_second=35
|
||||
)
|
||||
|
||||
broadcast_service = BroadcastService(bot=callback_query.bot, session=session, messages_per_second=35)
|
||||
|
||||
stats = await broadcast_service.broadcast(messages, workers=5)
|
||||
|
||||
duration_minutes = int(stats["total_duration"] // 60)
|
||||
duration_seconds = int(stats["total_duration"] % 60)
|
||||
duration_str = (
|
||||
f"{duration_minutes} мин {duration_seconds} сек"
|
||||
if duration_minutes > 0
|
||||
else f"{duration_seconds} сек"
|
||||
f"{duration_minutes} мин {duration_seconds} сек" if duration_minutes > 0 else f"{duration_seconds} сек"
|
||||
)
|
||||
|
||||
await callback_query.message.answer(
|
||||
|
||||
@@ -12,7 +12,7 @@ from logger import logger
|
||||
|
||||
|
||||
class BroadcastMessage:
|
||||
def __init__(self, tg_id: int, text: str, photo: str | None = None, keyboard: Any = None):
|
||||
def __init__(self, tg_id: int, text: str, photo: str | None = None, keyboard: Any = None) -> None:
|
||||
self.tg_id = tg_id
|
||||
self.text = text
|
||||
self.photo = photo
|
||||
@@ -22,17 +22,17 @@ class BroadcastMessage:
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
def __init__(self, max_rate: int = 35, window: float = 1.0):
|
||||
def __init__(self, max_rate: int = 35, window: float = 1.0) -> None:
|
||||
self.max_rate = max_rate
|
||||
self.window = window
|
||||
self.send_times = deque()
|
||||
self.lock = asyncio.Lock()
|
||||
|
||||
|
||||
def _clean_old_timestamps(self, current_time: float):
|
||||
cutoff_time = current_time - self.window
|
||||
while self.send_times and self.send_times[0] <= cutoff_time:
|
||||
self.send_times.popleft()
|
||||
|
||||
|
||||
async def acquire(self):
|
||||
async with self.lock:
|
||||
while True:
|
||||
@@ -46,14 +46,13 @@ class RateLimiter:
|
||||
|
||||
oldest_timestamp = self.send_times[0]
|
||||
time_to_wait = (oldest_timestamp + self.window) - now
|
||||
|
||||
|
||||
if time_to_wait > 0:
|
||||
await asyncio.sleep(time_to_wait + 0.001)
|
||||
|
||||
|
||||
class BroadcastService:
|
||||
|
||||
def __init__(self, bot: Bot, session: AsyncSession, messages_per_second: int = 35):
|
||||
def __init__(self, bot: Bot, session: AsyncSession, messages_per_second: int = 35) -> None:
|
||||
self.bot = bot
|
||||
self.session = session
|
||||
self.rate_limiter = RateLimiter(max_rate=messages_per_second)
|
||||
@@ -64,44 +63,36 @@ class BroadcastService:
|
||||
self.total_sent = 0
|
||||
self.start_time = None
|
||||
self.is_running = False
|
||||
|
||||
|
||||
async def _send_single_message(self, msg: BroadcastMessage) -> bool:
|
||||
try:
|
||||
await self.rate_limiter.acquire()
|
||||
|
||||
|
||||
if msg.photo:
|
||||
await self.bot.send_photo(
|
||||
chat_id=msg.tg_id,
|
||||
photo=msg.photo,
|
||||
caption=msg.text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=msg.keyboard
|
||||
chat_id=msg.tg_id, photo=msg.photo, caption=msg.text, parse_mode="HTML", reply_markup=msg.keyboard
|
||||
)
|
||||
else:
|
||||
await self.bot.send_message(
|
||||
chat_id=msg.tg_id,
|
||||
text=msg.text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=msg.keyboard
|
||||
chat_id=msg.tg_id, text=msg.text, parse_mode="HTML", reply_markup=msg.keyboard
|
||||
)
|
||||
|
||||
|
||||
return True
|
||||
|
||||
|
||||
except TelegramRetryAfter as e:
|
||||
msg.retry_after = e.retry_after
|
||||
msg.attempts += 1
|
||||
logger.warning(
|
||||
f"⚠️ Flood control для {msg.tg_id}: повтор через {e.retry_after} сек. "
|
||||
f"(попытка {msg.attempts})"
|
||||
f"⚠️ Flood control для {msg.tg_id}: повтор через {e.retry_after} сек. (попытка {msg.attempts})"
|
||||
)
|
||||
await self.delayed_queue.put(msg)
|
||||
return False
|
||||
|
||||
|
||||
except TelegramForbiddenError:
|
||||
logger.warning(f"🚫 Бот заблокирован пользователем {msg.tg_id}")
|
||||
self.blocked_users.add(msg.tg_id)
|
||||
return False
|
||||
|
||||
|
||||
except TelegramBadRequest as e:
|
||||
error_msg = str(e).lower()
|
||||
if "chat not found" in error_msg:
|
||||
@@ -110,17 +101,17 @@ class BroadcastService:
|
||||
else:
|
||||
logger.warning(f"📩 Не удалось отправить сообщение пользователю {msg.tg_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка отправки сообщения пользователю {msg.tg_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def _process_delayed_messages(self):
|
||||
while self.is_running:
|
||||
try:
|
||||
if not self.delayed_queue.empty():
|
||||
msg = await asyncio.wait_for(self.delayed_queue.get(), timeout=0.1)
|
||||
|
||||
|
||||
if msg.retry_after:
|
||||
await asyncio.sleep(msg.retry_after)
|
||||
msg.retry_after = None
|
||||
@@ -132,53 +123,52 @@ class BroadcastService:
|
||||
self.results.append(False)
|
||||
else:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
|
||||
except TimeoutError:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка в обработчике отложенных сообщений: {e}")
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
async def _worker(self):
|
||||
while self.is_running:
|
||||
try:
|
||||
msg = await asyncio.wait_for(self.queue.get(), timeout=0.1)
|
||||
|
||||
success = await self._send_single_message(msg)
|
||||
|
||||
|
||||
if success:
|
||||
self.total_sent += 1
|
||||
self.results.append(True)
|
||||
elif msg.attempts == 0:
|
||||
self.results.append(False)
|
||||
|
||||
|
||||
self.queue.task_done()
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
|
||||
except TimeoutError:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка в воркере рассылки: {e}")
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
async def _save_blocked_users(self):
|
||||
if not self.blocked_users:
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
|
||||
from database.models import BlockedUser
|
||||
|
||||
|
||||
values = [{"tg_id": tg_id} for tg_id in self.blocked_users]
|
||||
stmt = insert(BlockedUser).values(values).on_conflict_do_nothing(
|
||||
index_elements=[BlockedUser.tg_id]
|
||||
)
|
||||
stmt = insert(BlockedUser).values(values).on_conflict_do_nothing(index_elements=[BlockedUser.tg_id])
|
||||
await self.session.execute(stmt)
|
||||
await self.session.commit()
|
||||
logger.info(f"📝 Добавлено {len(self.blocked_users)} пользователей в blocked_users")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при сохранении заблокированных пользователей: {e}")
|
||||
await self.session.rollback()
|
||||
|
||||
|
||||
async def broadcast(self, messages: list[dict], workers: int = 20) -> dict:
|
||||
self.is_running = True
|
||||
self.start_time = time.time()
|
||||
@@ -191,10 +181,10 @@ class BroadcastService:
|
||||
tg_id=msg_data["tg_id"],
|
||||
text=msg_data["text"],
|
||||
photo=msg_data.get("photo"),
|
||||
keyboard=msg_data.get("keyboard")
|
||||
keyboard=msg_data.get("keyboard"),
|
||||
)
|
||||
await self.queue.put(msg)
|
||||
|
||||
|
||||
logger.info(f"📤 Начата рассылка на {len(messages)} пользователей с {workers} воркерами")
|
||||
|
||||
worker_tasks = [asyncio.create_task(self._worker()) for _ in range(workers)]
|
||||
@@ -221,7 +211,7 @@ class BroadcastService:
|
||||
total_duration = end_time - self.start_time
|
||||
success_count = sum(1 for r in self.results if r)
|
||||
avg_speed = self.total_sent / total_duration if total_duration > 0 else 0
|
||||
|
||||
|
||||
stats = {
|
||||
"total_duration": total_duration,
|
||||
"total_sent": self.total_sent,
|
||||
@@ -229,13 +219,12 @@ class BroadcastService:
|
||||
"failed_count": len(self.results) - success_count,
|
||||
"avg_speed": avg_speed,
|
||||
"total_messages": len(messages),
|
||||
"blocked_users": len(self.blocked_users)
|
||||
"blocked_users": len(self.blocked_users),
|
||||
}
|
||||
|
||||
|
||||
logger.info(
|
||||
f"✅ Рассылка завершена: {success_count}/{len(messages)} успешно, "
|
||||
f"скорость: {avg_speed:.1f} сообщений/сек, время: {total_duration:.1f} сек"
|
||||
)
|
||||
|
||||
return stats
|
||||
|
||||
return stats
|
||||
|
||||
@@ -4,4 +4,3 @@ from aiogram.fsm.state import State, StatesGroup
|
||||
class AdminSender(StatesGroup):
|
||||
waiting_for_message = State()
|
||||
preview = State()
|
||||
|
||||
|
||||
@@ -7,33 +7,24 @@ from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy import distinct, exists, func, not_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.constants import PAYMENT_SYSTEMS_EXCLUDED
|
||||
from database.models import BlockedUser, Key, ManualBan, Payment, Server, Tariff, User
|
||||
from logger import logger
|
||||
from core.constants import PAYMENT_SYSTEMS_EXCLUDED
|
||||
|
||||
|
||||
async def get_recipients(
|
||||
session: AsyncSession,
|
||||
send_to: str,
|
||||
cluster_name: str | None = None
|
||||
) -> tuple[list[int], int]:
|
||||
async def get_recipients(session: AsyncSession, send_to: str, cluster_name: str | None = None) -> tuple[list[int], int]:
|
||||
now_ms = int(datetime.utcnow().timestamp() * 1000)
|
||||
banned_tg_ids = select(BlockedUser.tg_id).union_all(
|
||||
select(ManualBan.tg_id).where(
|
||||
(ManualBan.until.is_(None)) | (ManualBan.until > datetime.utcnow())
|
||||
)
|
||||
select(ManualBan.tg_id).where((ManualBan.until.is_(None)) | (ManualBan.until > datetime.utcnow()))
|
||||
)
|
||||
|
||||
query = None
|
||||
|
||||
|
||||
if send_to == "subscribed":
|
||||
query = (
|
||||
select(distinct(User.tg_id))
|
||||
.join(Key)
|
||||
.where(Key.expiry_time > now_ms)
|
||||
.where(~User.tg_id.in_(banned_tg_ids))
|
||||
select(distinct(User.tg_id)).join(Key).where(Key.expiry_time > now_ms).where(~User.tg_id.in_(banned_tg_ids))
|
||||
)
|
||||
|
||||
|
||||
elif send_to == "unsubscribed":
|
||||
subquery = (
|
||||
select(User.tg_id)
|
||||
@@ -48,7 +39,7 @@ async def get_recipients(
|
||||
)
|
||||
)
|
||||
query = select(distinct(subquery.c.tg_id)).where(~subquery.c.tg_id.in_(banned_tg_ids))
|
||||
|
||||
|
||||
elif send_to == "untrial":
|
||||
subquery = select(Key.tg_id)
|
||||
query = (
|
||||
@@ -56,7 +47,7 @@ async def get_recipients(
|
||||
.where(~User.tg_id.in_(subquery) & User.trial.in_([0, -1]))
|
||||
.where(~User.tg_id.in_(banned_tg_ids))
|
||||
)
|
||||
|
||||
|
||||
elif send_to == "cluster":
|
||||
query = (
|
||||
select(distinct(User.tg_id))
|
||||
@@ -65,7 +56,7 @@ async def get_recipients(
|
||||
.where(Server.cluster_name == cluster_name)
|
||||
.where(~User.tg_id.in_(banned_tg_ids))
|
||||
)
|
||||
|
||||
|
||||
elif send_to == "hotleads":
|
||||
subquery_active_keys = select(Key.tg_id).where(Key.expiry_time > now_ms).distinct()
|
||||
query = (
|
||||
@@ -77,7 +68,7 @@ async def get_recipients(
|
||||
.where(not_(exists(subquery_active_keys.where(Key.tg_id == User.tg_id))))
|
||||
.where(~User.tg_id.in_(banned_tg_ids))
|
||||
)
|
||||
|
||||
|
||||
elif send_to == "trial":
|
||||
trial_tariff_subquery = select(Tariff.id).where(Tariff.group_code == "trial")
|
||||
query = (
|
||||
@@ -85,7 +76,7 @@ async def get_recipients(
|
||||
.where(Key.tariff_id.in_(trial_tariff_subquery))
|
||||
.where(~Key.tg_id.in_(banned_tg_ids))
|
||||
)
|
||||
|
||||
|
||||
else:
|
||||
query = select(distinct(User.tg_id)).where(~User.tg_id.in_(banned_tg_ids))
|
||||
|
||||
@@ -96,22 +87,22 @@ async def get_recipients(
|
||||
|
||||
def strip_html_tags(text: str) -> str:
|
||||
text = re.sub(r'<tg-emoji emoji-id="[^"]*">([^<]*)</tg-emoji>', r"\1", text)
|
||||
text = re.sub(r'<[^>]+>', '', text)
|
||||
text = text.replace('<', '<').replace('>', '>').replace('&', '&')
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
text = text.replace("<", "<").replace(">", ">").replace("&", "&")
|
||||
return text.strip()
|
||||
|
||||
|
||||
def parse_message_buttons(text: str) -> tuple[str, InlineKeyboardMarkup | None]:
|
||||
buttons_match = re.search(r'(<[^>]+>)?\s*BUTTONS\s*:\s*(</[^>]+>)?', text, re.IGNORECASE)
|
||||
buttons_match = re.search(r"(<[^>]+>)?\s*BUTTONS\s*:\s*(</[^>]+>)?", text, re.IGNORECASE)
|
||||
if not buttons_match:
|
||||
return text, None
|
||||
|
||||
clean_text = text[:buttons_match.start()].strip()
|
||||
clean_text = text[: buttons_match.start()].strip()
|
||||
|
||||
buttons_section = text[buttons_match.start():].strip()
|
||||
buttons_section = text[buttons_match.start() :].strip()
|
||||
buttons_text = strip_html_tags(buttons_section)
|
||||
|
||||
buttons_text = re.sub(r'^.*?BUTTONS\s*:\s*', '', buttons_text, flags=re.IGNORECASE).strip()
|
||||
buttons_text = re.sub(r"^.*?BUTTONS\s*:\s*", "", buttons_text, flags=re.IGNORECASE).strip()
|
||||
|
||||
if not buttons_text:
|
||||
return clean_text, None
|
||||
@@ -156,4 +147,3 @@ def parse_message_buttons(text: str) -> tuple[str, InlineKeyboardMarkup | None]:
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
return clean_text, keyboard
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from .settings_manage import router as settings_manage_router
|
||||
from .settings_modes import router as settings_modes_router
|
||||
from .settings_money import router as settings_panels_router
|
||||
from .settings_notifications import router as settings_notifications_router
|
||||
from .settings_tariffs import router as settings_tariffs_router
|
||||
|
||||
|
||||
router = Router(name="admin_settings")
|
||||
@@ -15,3 +16,4 @@ router.include_router(settings_cashboxes_router)
|
||||
router.include_router(settings_panels_router)
|
||||
router.include_router(settings_notifications_router)
|
||||
router.include_router(settings_modes_router)
|
||||
router.include_router(settings_tariffs_router)
|
||||
|
||||
@@ -4,11 +4,11 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
|
||||
from .settings_config import (
|
||||
BUTTON_TITLES,
|
||||
NOTIFICATION_TITLES,
|
||||
NOTIFICATION_TIME_FIELDS,
|
||||
PAYMENT_PROVIDER_TITLES,
|
||||
MODES_TITLES,
|
||||
MONEY_FIELDS,
|
||||
NOTIFICATION_TIME_FIELDS,
|
||||
NOTIFICATION_TITLES,
|
||||
PAYMENT_PROVIDER_TITLES,
|
||||
)
|
||||
|
||||
|
||||
@@ -73,8 +73,12 @@ def build_settings_kb() -> InlineKeyboardMarkup:
|
||||
text="Режимы",
|
||||
callback_data=AdminPanelCallback(action="settings_modes").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="Тарификация",
|
||||
callback_data=AdminPanelCallback(action="settings_tariffs").pack(),
|
||||
)
|
||||
|
||||
builder.adjust(2, 2, 1)
|
||||
builder.adjust(2, 2, 2)
|
||||
builder.row(build_admin_back_btn())
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from typing import Final
|
||||
|
||||
|
||||
BUTTON_TITLES: Final[dict[str, str]] = {
|
||||
"CHANNEL_BUTTON_ENABLE": "Канал",
|
||||
"DONATIONS_BUTTON_ENABLE": "Донаты",
|
||||
@@ -13,6 +14,7 @@ BUTTON_TITLES: Final[dict[str, str]] = {
|
||||
"TOP_REFERRAL_BUTTON_ENABLE": "Топ-5 рефералов",
|
||||
"QRCODE_BUTTON_ENABLE": "QR подписки",
|
||||
"HWID_RESET_BUTTON_ENABLE": "Сброс HWID",
|
||||
"ANDROID_TV_BUTTON_ENABLE": "Android TV",
|
||||
}
|
||||
|
||||
NOTIFICATION_TITLES: Final[dict[str, str]] = {
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.settings.tariffs_config import TARIFFS_CONFIG, update_tariffs_config
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
PACK_MODES = ["", "traffic", "devices", "all"]
|
||||
|
||||
|
||||
def format_pack_mode_label(mode: str | None) -> str:
|
||||
"""Возвращает человекочитаемое название режима пакетов."""
|
||||
if not mode:
|
||||
return "выкл"
|
||||
if mode == "traffic":
|
||||
return "только трафик"
|
||||
if mode == "devices":
|
||||
return "только устройства"
|
||||
if mode == "all":
|
||||
return "трафик и устройства"
|
||||
return f"неизвестно ({mode})"
|
||||
|
||||
|
||||
def build_tariffs_settings_kb() -> InlineKeyboardMarkup:
|
||||
"""Клавиатура основного экрана настроек тарификации."""
|
||||
allow_downgrade = bool(TARIFFS_CONFIG.get("ALLOW_DOWNGRADE", True))
|
||||
pack_mode = TARIFFS_CONFIG.get("KEY_ADDONS_PACK_MODE") or ""
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"Понижение: {'вкл' if allow_downgrade else 'выкл'}",
|
||||
callback_data=AdminPanelCallback(action="settings_tariffs_toggle_downgrade").pack(),
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"Режим пакетов: {format_pack_mode_label(pack_mode)}",
|
||||
callback_data=AdminPanelCallback(action="settings_tariffs_packs").pack(),
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="Назад",
|
||||
callback_data=AdminPanelCallback(action="settings").pack(),
|
||||
)
|
||||
)
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_tariffs_settings_text() -> str:
|
||||
"""Текст основного экрана настроек тарификации."""
|
||||
allow_downgrade = bool(TARIFFS_CONFIG.get("ALLOW_DOWNGRADE", True))
|
||||
pack_mode = TARIFFS_CONFIG.get("KEY_ADDONS_PACK_MODE") or ""
|
||||
|
||||
lines: list[str] = [
|
||||
"⚙️ Настройки тарификации",
|
||||
"",
|
||||
f"• Понижение условий при изменении: {'включено' if allow_downgrade else 'выключено'}",
|
||||
f"• Режим доплат пакетами: {format_pack_mode_label(pack_mode)}",
|
||||
"",
|
||||
"Нажмите «Режим пакетов», чтобы подробнее настроить доплаты к активной подписке.",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def refresh_tariffs_settings_screen(callback: CallbackQuery) -> None:
|
||||
"""Обновляет основной экран настроек тарификации."""
|
||||
await callback.message.edit_text(
|
||||
build_tariffs_settings_text(),
|
||||
reply_markup=build_tariffs_settings_kb(),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def build_tariffs_packs_kb() -> InlineKeyboardMarkup:
|
||||
"""Клавиатура экрана выбора режима пакетов."""
|
||||
current = TARIFFS_CONFIG.get("KEY_ADDONS_PACK_MODE") or ""
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"{'✅ ' if current == '' else ''}Выкл",
|
||||
callback_data=AdminPanelCallback(action="settings_tariffs_mode_off").pack(),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"{'✅ ' if current == 'traffic' else ''}Только трафик",
|
||||
callback_data=AdminPanelCallback(action="settings_tariffs_mode_traffic").pack(),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"{'✅ ' if current == 'devices' else ''}Только устройства",
|
||||
callback_data=AdminPanelCallback(action="settings_tariffs_mode_devices").pack(),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"{'✅ ' if current == 'all' else ''}Трафик и устройства",
|
||||
callback_data=AdminPanelCallback(action="settings_tariffs_mode_all").pack(),
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
callback_data=AdminPanelCallback(action="settings_tariffs").pack(),
|
||||
)
|
||||
)
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_tariffs_packs_text() -> str:
|
||||
"""Текст экрана выбора режима пакетов."""
|
||||
current = TARIFFS_CONFIG.get("KEY_ADDONS_PACK_MODE") or ""
|
||||
|
||||
lines: list[str] = [
|
||||
"📦 Режим доплат пакетами",
|
||||
"",
|
||||
"При активной подписке можно продавать не новый тариф, а доплаты к текущим лимитам.",
|
||||
"",
|
||||
"Режимы:",
|
||||
"• Выкл — доплаты пакетами отключены, работает обычный конфигуратор лимитов (клиент меняет конфигурацию тарифа для продления).",
|
||||
"• Только трафик — пользователь докупает ГБ до продления, количество устройств не меняется.",
|
||||
"• Только устройства — пользователь докупает устройства до продления, лимит трафика не меняется.",
|
||||
"• Трафик и устройства — можно одновременно докупать и трафик, и устройства до продления как единый пакет.",
|
||||
"",
|
||||
f"Текущий режим: {format_pack_mode_label(current)}",
|
||||
"",
|
||||
"Выберите режим, который будет использоваться при доплате за расширение уже существующей подписки.",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def refresh_tariffs_packs_screen(callback: CallbackQuery) -> None:
|
||||
"""Обновляет экран выбора режима пакетов."""
|
||||
await callback.message.edit_text(
|
||||
build_tariffs_packs_text(),
|
||||
reply_markup=build_tariffs_packs_kb(),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_tariffs"))
|
||||
async def open_tariffs_settings(callback: CallbackQuery, session: AsyncSession) -> None:
|
||||
"""Открывает основной экран настроек тарификации."""
|
||||
await refresh_tariffs_settings_screen(callback)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_tariffs_toggle_downgrade"))
|
||||
async def toggle_tariffs_downgrade(callback: CallbackQuery, session: AsyncSession) -> None:
|
||||
"""Переключает флаг понижения условий."""
|
||||
current = bool(TARIFFS_CONFIG.get("ALLOW_DOWNGRADE", True))
|
||||
|
||||
new_config: dict[str, Any] = dict(TARIFFS_CONFIG)
|
||||
new_config["ALLOW_DOWNGRADE"] = not current
|
||||
|
||||
await update_tariffs_config(session, new_config)
|
||||
await refresh_tariffs_settings_screen(callback)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_tariffs_packs"))
|
||||
async def open_tariffs_packs(callback: CallbackQuery, session: AsyncSession) -> None:
|
||||
"""Открывает экран выбора режима пакетов."""
|
||||
await refresh_tariffs_packs_screen(callback)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(
|
||||
F.action.in_([
|
||||
"settings_tariffs_mode_off",
|
||||
"settings_tariffs_mode_traffic",
|
||||
"settings_tariffs_mode_devices",
|
||||
"settings_tariffs_mode_all",
|
||||
])
|
||||
)
|
||||
)
|
||||
async def set_tariffs_pack_mode(
|
||||
callback: CallbackQuery,
|
||||
callback_data: AdminPanelCallback,
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
"""Сохраняет выбранный режим пакетов и обновляет экран."""
|
||||
action = callback_data.action
|
||||
|
||||
if action == "settings_tariffs_mode_off":
|
||||
new_mode = ""
|
||||
elif action == "settings_tariffs_mode_traffic":
|
||||
new_mode = "traffic"
|
||||
elif action == "settings_tariffs_mode_devices":
|
||||
new_mode = "devices"
|
||||
elif action == "settings_tariffs_mode_all":
|
||||
new_mode = "all"
|
||||
else:
|
||||
new_mode = TARIFFS_CONFIG.get("KEY_ADDONS_PACK_MODE") or ""
|
||||
|
||||
new_config: dict[str, Any] = dict(TARIFFS_CONFIG)
|
||||
new_config["KEY_ADDONS_PACK_MODE"] = new_mode
|
||||
|
||||
await update_tariffs_config(session, new_config)
|
||||
await refresh_tariffs_packs_screen(callback)
|
||||
@@ -3,8 +3,9 @@ from aiogram import Router
|
||||
|
||||
router = Router()
|
||||
|
||||
from . import (
|
||||
tariff_manage, # noqa: F401
|
||||
tariff_sorting, # noqa: F401
|
||||
tariff_subgroups, # noqa: F401
|
||||
from . import ( # noqa: F401
|
||||
tariff_configurator,
|
||||
tariff_manage,
|
||||
tariff_sorting,
|
||||
tariff_subgroups,
|
||||
)
|
||||
|
||||
@@ -204,9 +204,31 @@ def build_tariff_list_kb(tariffs: list[dict]) -> InlineKeyboardMarkup:
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_single_tariff_kb(tariff_id: int, group_code: str = None) -> InlineKeyboardMarkup:
|
||||
def build_single_tariff_kb(
|
||||
tariff_id: int,
|
||||
group_code: str | None = None,
|
||||
configurable: bool | None = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
configurator_title = "⚙️ Конфигуратор"
|
||||
if configurable is True:
|
||||
configurator_title += " ✅"
|
||||
elif configurable is False:
|
||||
configurator_title += " ❌"
|
||||
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=configurator_title,
|
||||
callback_data=f"toggle_configurable|{tariff_id}",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🔧 Настройки конфигуратора",
|
||||
callback_data=f"edit_config|{tariff_id}",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="✏️ Редактировать",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
from . import ( # noqa: F401
|
||||
common,
|
||||
device_pricing,
|
||||
options,
|
||||
traffic_pricing,
|
||||
)
|
||||
@@ -0,0 +1,387 @@
|
||||
from aiogram import F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.settings.tariffs_config import normalize_tariff_config
|
||||
from database.models import Tariff
|
||||
from filters.admin import IsAdminFilter
|
||||
|
||||
from .. import router
|
||||
from ..keyboard import AdminTariffCallback
|
||||
|
||||
|
||||
class TariffConfigState(StatesGroup):
|
||||
choosing_section = State()
|
||||
entering_devices = State()
|
||||
entering_traffic = State()
|
||||
entering_device_step = State()
|
||||
entering_device_overrides = State()
|
||||
entering_traffic_step = State()
|
||||
entering_traffic_overrides = State()
|
||||
|
||||
|
||||
def build_config_menu_kb(tariff_id: int) -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📱 Варианты устройств",
|
||||
callback_data=f"cfg_edit_devices|{tariff_id}",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📦 Варианты трафика",
|
||||
callback_data=f"cfg_edit_traffic|{tariff_id}",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="💰 Шаг доплаты за устройства",
|
||||
callback_data=f"cfg_edit_device_step|{tariff_id}",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📊 Доплаты по вариантам устройств",
|
||||
callback_data=f"cfg_edit_device_over|{tariff_id}",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="💰 Шаг доплаты за трафик (ГБ)",
|
||||
callback_data=f"cfg_edit_traffic_step|{tariff_id}",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📊 Доплаты по вариантам трафика",
|
||||
callback_data=f"cfg_edit_traffic_over|{tariff_id}",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад к тарифу",
|
||||
callback_data=AdminTariffCallback(action=f"view|{tariff_id}").pack(),
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def build_cancel_config_kb(tariff_id: int) -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="❌ Отмена",
|
||||
callback_data=f"edit_config|{tariff_id}",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def calculate_device_formula_extra(tariff: Tariff, devices: int) -> int:
|
||||
base_devices = tariff.device_limit
|
||||
step = getattr(tariff, "device_step_rub", None) or 0
|
||||
if base_devices is None or devices <= base_devices:
|
||||
return 0
|
||||
return (devices - base_devices) * step
|
||||
|
||||
|
||||
def calculate_traffic_formula_extra(tariff: Tariff, gb_value: int) -> int:
|
||||
base_traffic = tariff.traffic_limit
|
||||
step = getattr(tariff, "traffic_step_rub", None) or 0
|
||||
if gb_value == 0:
|
||||
return 0
|
||||
if base_traffic is None or gb_value <= base_traffic:
|
||||
return 0
|
||||
return (gb_value - base_traffic) * step
|
||||
|
||||
|
||||
def build_device_overrides_screen(tariff: Tariff) -> tuple[str, InlineKeyboardMarkup]:
|
||||
tariff_id = tariff.id
|
||||
base_price = int(tariff.price_rub or 0)
|
||||
device_options = tariff.device_options or []
|
||||
overrides = getattr(tariff, "device_overrides", None) or {}
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append("📊 Доплаты по вариантам устройств.")
|
||||
lines.append("")
|
||||
lines.append(f"Базовая цена тарифа: <b>{base_price}₽</b>")
|
||||
lines.append("Ниже показаны варианты устройств и текущая доплата.")
|
||||
lines.append("Значение <code>0</code> можно использовать как безлимит по устройствам.")
|
||||
lines.append("Нажмите на вариант, чтобы задать доплату в рублях.")
|
||||
lines.append("Отправьте <code>0</code>, чтобы вернуть расчёт по базовому шагу.")
|
||||
lines.append("")
|
||||
lines.append("Текущие значения:")
|
||||
|
||||
for devices in sorted(device_options):
|
||||
key = str(devices)
|
||||
formula_extra = calculate_device_formula_extra(tariff, devices)
|
||||
override_extra = overrides.get(key)
|
||||
if override_extra is not None:
|
||||
effective_extra = int(override_extra)
|
||||
status = " (индивидуальная доплата)"
|
||||
else:
|
||||
effective_extra = formula_extra
|
||||
status = ""
|
||||
if devices == 0:
|
||||
label = "безлимит устройств"
|
||||
else:
|
||||
label = f"{devices} устр."
|
||||
lines.append(f"• {label}: доплата {effective_extra}₽{status}")
|
||||
|
||||
text = "\n".join(lines)
|
||||
|
||||
rows: list[list[InlineKeyboardButton]] = []
|
||||
for devices in sorted(device_options):
|
||||
key = str(devices)
|
||||
formula_extra = calculate_device_formula_extra(tariff, devices)
|
||||
override_extra = overrides.get(key)
|
||||
if override_extra is not None:
|
||||
effective_extra = int(override_extra)
|
||||
status = "★"
|
||||
else:
|
||||
effective_extra = formula_extra
|
||||
status = ""
|
||||
if devices == 0:
|
||||
name = "безлимит устройств"
|
||||
else:
|
||||
name = f"{devices} устр."
|
||||
if effective_extra > 0:
|
||||
label = f"{status} {name} — доплата +{effective_extra}₽"
|
||||
else:
|
||||
label = f"{status} {name} — без доплаты"
|
||||
label = label.strip()
|
||||
rows.append([
|
||||
InlineKeyboardButton(
|
||||
text=label,
|
||||
callback_data=f"cfg_dev_over_item|{tariff_id}|{devices}",
|
||||
)
|
||||
])
|
||||
|
||||
rows.append([
|
||||
InlineKeyboardButton(
|
||||
text="🧹 Сбросить все индивидуальные доплаты",
|
||||
callback_data=f"cfg_dev_over_clear|{tariff_id}",
|
||||
)
|
||||
])
|
||||
rows.append([
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад к конфигуратору",
|
||||
callback_data=f"edit_config|{tariff_id}",
|
||||
)
|
||||
])
|
||||
|
||||
markup = InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
return text, markup
|
||||
|
||||
|
||||
def build_traffic_overrides_screen(tariff: Tariff) -> tuple[str, InlineKeyboardMarkup]:
|
||||
tariff_id = tariff.id
|
||||
base_price = int(tariff.price_rub or 0)
|
||||
traffic_options = tariff.traffic_options_gb or []
|
||||
overrides = getattr(tariff, "traffic_overrides", None) or {}
|
||||
|
||||
all_options = sorted(set(traffic_options + [0]))
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append("📊 Доплаты по вариантам трафика.")
|
||||
lines.append("")
|
||||
lines.append(f"Базовая цена тарифа: <b>{base_price}₽</b>")
|
||||
lines.append("Ниже показаны варианты лимитов и текущая доплата.")
|
||||
lines.append("Значение <code>0</code> — безлимитный трафик.")
|
||||
lines.append("Нажмите на вариант, чтобы задать доплату в рублях.")
|
||||
lines.append("Отправьте <code>0</code>, чтобы вернуть расчёт по базовому шагу.")
|
||||
lines.append("")
|
||||
lines.append("Текущие значения:")
|
||||
|
||||
for gb in all_options:
|
||||
key = str(gb)
|
||||
formula_extra = calculate_traffic_formula_extra(tariff, gb)
|
||||
override_extra = overrides.get(key)
|
||||
if override_extra is not None:
|
||||
effective_extra = int(override_extra)
|
||||
status = " (индивидуальная доплата)"
|
||||
else:
|
||||
effective_extra = formula_extra
|
||||
status = ""
|
||||
if gb == 0:
|
||||
label = "безлимит"
|
||||
else:
|
||||
label = f"{gb} ГБ"
|
||||
lines.append(f"• {label}: доплата {effective_extra}₽{status}")
|
||||
|
||||
text = "\n".join(lines)
|
||||
|
||||
rows: list[list[InlineKeyboardButton]] = []
|
||||
for gb in all_options:
|
||||
key = str(gb)
|
||||
formula_extra = calculate_traffic_formula_extra(tariff, gb)
|
||||
override_extra = overrides.get(key)
|
||||
if override_extra is not None:
|
||||
effective_extra = int(override_extra)
|
||||
status = "★"
|
||||
else:
|
||||
effective_extra = formula_extra
|
||||
status = ""
|
||||
if gb == 0:
|
||||
name = "безлимит"
|
||||
else:
|
||||
name = f"{gb} ГБ"
|
||||
if effective_extra > 0:
|
||||
label = f"{status} {name} — доплата +{effective_extra}₽"
|
||||
else:
|
||||
label = f"{status} {name} — без доплаты"
|
||||
label = label.strip()
|
||||
rows.append([
|
||||
InlineKeyboardButton(
|
||||
text=label,
|
||||
callback_data=f"cfg_trf_over_item|{tariff_id}|{gb}",
|
||||
)
|
||||
])
|
||||
|
||||
rows.append([
|
||||
InlineKeyboardButton(
|
||||
text="🧹 Сбросить все индивидуальные доплаты",
|
||||
callback_data=f"cfg_trf_over_clear|{tariff_id}",
|
||||
)
|
||||
])
|
||||
rows.append([
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад к конфигуратору",
|
||||
callback_data=f"edit_config|{tariff_id}",
|
||||
)
|
||||
])
|
||||
|
||||
markup = InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
return text, markup
|
||||
|
||||
|
||||
def build_config_summary_text(tariff: Tariff) -> str:
|
||||
cfg = normalize_tariff_config(tariff.to_dict())
|
||||
configurable_text = "включен" if getattr(tariff, "configurable", False) else "выключен"
|
||||
|
||||
base_duration = tariff.duration_days
|
||||
base_devices = tariff.device_limit if tariff.device_limit is not None else "—"
|
||||
if tariff.traffic_limit is None:
|
||||
base_traffic_text = "безлимит"
|
||||
else:
|
||||
base_traffic_text = f"{tariff.traffic_limit} ГБ"
|
||||
base_price = tariff.price_rub or 0
|
||||
|
||||
device_options = cfg.get("device_options") or []
|
||||
traffic_options_gb = cfg.get("traffic_options_gb")
|
||||
|
||||
duration_line = f"📅 Длительность: фиксированная, {base_duration} дн."
|
||||
|
||||
if device_options:
|
||||
devices_parts = []
|
||||
for d in device_options:
|
||||
if d == 0:
|
||||
devices_parts.append("безлимит")
|
||||
else:
|
||||
devices_parts.append(str(d))
|
||||
devices_str = ", ".join(devices_parts)
|
||||
devices_line = f"📱 Устройства: варианты — {devices_str}"
|
||||
else:
|
||||
devices_line = f"📱 Устройства: выбор отключён, по умолчанию {base_devices}"
|
||||
|
||||
if traffic_options_gb is None:
|
||||
traffic_line = "📦 Трафик: выбор трафика отключён"
|
||||
else:
|
||||
traffic_parts = []
|
||||
for g in traffic_options_gb:
|
||||
if g == 0:
|
||||
traffic_parts.append("безлимит")
|
||||
else:
|
||||
traffic_parts.append(f"{g} ГБ")
|
||||
traffic_str = ", ".join(traffic_parts)
|
||||
traffic_line = f"📦 Трафик: варианты — {traffic_str}"
|
||||
|
||||
device_step = getattr(tariff, "device_step_rub", None) or 0
|
||||
traffic_step = getattr(tariff, "traffic_step_rub", None) or 0
|
||||
|
||||
device_overrides = getattr(tariff, "device_overrides", None) or {}
|
||||
traffic_overrides = getattr(tariff, "traffic_overrides", None) or {}
|
||||
|
||||
base_block = (
|
||||
"<blockquote>"
|
||||
"🎯 База тарифа:\n"
|
||||
f"• Длительность: <b>{base_duration} дней</b>\n"
|
||||
f"• Устройства: <b>{base_devices}</b>\n"
|
||||
f"• Трафик: <b>{base_traffic_text}</b>\n"
|
||||
f"• Цена: <b>{base_price}₽</b>\n"
|
||||
"</blockquote>\n"
|
||||
)
|
||||
|
||||
config_block = f"<blockquote>\n{duration_line}\n{devices_line}\n{traffic_line}\n</blockquote>\n"
|
||||
|
||||
device_step_line = (
|
||||
f"💰 Устройства, базовый шаг: {device_step}₽ за каждое устройство сверх базового лимита ({base_devices})"
|
||||
)
|
||||
|
||||
if device_overrides:
|
||||
parts = []
|
||||
for k, v in sorted(device_overrides.items(), key=lambda x: int(x[0])):
|
||||
devices_count = int(k)
|
||||
extra = int(v)
|
||||
if devices_count == 0:
|
||||
label = "безлимит устройств"
|
||||
else:
|
||||
label = f"{devices_count} устройств"
|
||||
parts.append(f"при {label}: индивидуальная доплата {extra}₽")
|
||||
device_over_line = "📊 Устройства, индивидуальные доплаты:\n" + "\n".join(f"• {p}" for p in parts)
|
||||
else:
|
||||
device_over_line = "📊 Устройства, индивидуальные доплаты: не заданы"
|
||||
|
||||
device_block = f"<blockquote>{device_step_line}\n{device_over_line}\n</blockquote>\n"
|
||||
|
||||
traffic_step_line = f"💰 Трафик, базовый шаг: {traffic_step}₽ за 1 ГБ сверх базового лимита ({base_traffic_text})"
|
||||
|
||||
if traffic_overrides:
|
||||
parts = []
|
||||
for k, v in sorted(traffic_overrides.items(), key=lambda x: int(x[0])):
|
||||
gb_value = int(k)
|
||||
extra = int(v)
|
||||
if gb_value == 0:
|
||||
label = "безлимитный трафик"
|
||||
else:
|
||||
label = f"лимит {gb_value} ГБ"
|
||||
parts.append(f"при {label}: индивидуальная доплата {extra}₽")
|
||||
traffic_over_line = "📊 Трафик, индивидуальные доплаты:\n" + "\n".join(f"• {p}" for p in parts)
|
||||
else:
|
||||
traffic_over_line = "📊 Трафик, индивидуальные доплаты: не заданы"
|
||||
|
||||
traffic_block = f"<blockquote>\n{traffic_step_line}\n{traffic_over_line}\n</blockquote>"
|
||||
|
||||
return (
|
||||
f"<b>⚙️ Конфигуратор тарифа: {tariff.name}</b>\n\n"
|
||||
f"Статус: <b>{configurable_text}</b>\n\n"
|
||||
f"{base_block}"
|
||||
f"{config_block}"
|
||||
f"{device_block}"
|
||||
f"{traffic_block}"
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("edit_config|"), IsAdminFilter())
|
||||
async def open_config_menu(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
tariff_id = int(callback.data.split("|")[1])
|
||||
|
||||
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||
tariff = result.scalar_one_or_none()
|
||||
if not tariff:
|
||||
await callback.message.edit_text("❌ Тариф не найден.")
|
||||
return
|
||||
|
||||
await state.set_state(TariffConfigState.choosing_section)
|
||||
await state.update_data(tariff_id=tariff_id)
|
||||
|
||||
text = build_config_summary_text(tariff)
|
||||
await callback.message.edit_text(text=text, reply_markup=build_config_menu_kb(tariff_id))
|
||||
@@ -0,0 +1,202 @@
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Tariff
|
||||
from filters.admin import IsAdminFilter
|
||||
|
||||
from .. import router
|
||||
from .common import (
|
||||
TariffConfigState,
|
||||
build_cancel_config_kb,
|
||||
build_config_menu_kb,
|
||||
build_config_summary_text,
|
||||
build_device_overrides_screen,
|
||||
calculate_device_formula_extra,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("cfg_edit_device_step|"), TariffConfigState.choosing_section, IsAdminFilter())
|
||||
async def ask_device_step(callback: CallbackQuery, state: FSMContext):
|
||||
tariff_id = int(callback.data.split("|")[1])
|
||||
await state.set_state(TariffConfigState.entering_device_step)
|
||||
await state.update_data(tariff_id=tariff_id)
|
||||
|
||||
text = (
|
||||
"💰 Базовый шаг доплаты за устройства.\n\n"
|
||||
"Введите цену в рублях за КАЖДОЕ устройство сверх базового лимита.\n"
|
||||
"Например: <code>50</code>\n\n"
|
||||
"Чтобы выключить автоматическую доплату за устройства, отправьте <code>0</code>."
|
||||
)
|
||||
await callback.message.edit_text(text=text, reply_markup=build_cancel_config_kb(tariff_id))
|
||||
|
||||
|
||||
@router.message(TariffConfigState.entering_device_step, IsAdminFilter())
|
||||
async def save_device_step(message: Message, state: FSMContext, session: AsyncSession):
|
||||
data = await state.get_data()
|
||||
tariff_id = data["tariff_id"]
|
||||
|
||||
try:
|
||||
price = int(message.text.strip())
|
||||
if price < 0:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
await message.answer(
|
||||
"❌ Некорректное значение. Введите целое число 0 или больше.",
|
||||
reply_markup=build_cancel_config_kb(tariff_id),
|
||||
)
|
||||
return
|
||||
|
||||
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||
tariff = result.scalar_one_or_none()
|
||||
if not tariff:
|
||||
await message.answer("❌ Тариф не найден.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
tariff.device_step_rub = price
|
||||
tariff.updated_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
|
||||
await state.set_state(TariffConfigState.choosing_section)
|
||||
|
||||
text = build_config_summary_text(tariff)
|
||||
await message.answer(text=text, reply_markup=build_config_menu_kb(tariff_id))
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("cfg_edit_device_over|"), TariffConfigState.choosing_section, IsAdminFilter())
|
||||
async def open_device_overrides_menu(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
tariff_id = int(callback.data.split("|")[1])
|
||||
|
||||
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||
tariff = result.scalar_one_or_none()
|
||||
if not tariff:
|
||||
await callback.message.edit_text("❌ Тариф не найден.")
|
||||
return
|
||||
|
||||
device_options = tariff.device_options or []
|
||||
if not device_options:
|
||||
await callback.message.edit_text(
|
||||
"Сначала настройте варианты устройств (кнопка «📱 Варианты устройств»).",
|
||||
reply_markup=build_config_menu_kb(tariff_id),
|
||||
)
|
||||
return
|
||||
|
||||
await state.set_state(TariffConfigState.entering_device_overrides)
|
||||
await state.update_data(tariff_id=tariff_id, devices_override=None)
|
||||
|
||||
text, markup = build_device_overrides_screen(tariff)
|
||||
await callback.message.edit_text(text=text, reply_markup=markup)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
F.data.startswith("cfg_dev_over_item|"), TariffConfigState.entering_device_overrides, IsAdminFilter()
|
||||
)
|
||||
async def choose_device_override_option(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
parts = callback.data.split("|")
|
||||
tariff_id = int(parts[1])
|
||||
devices = int(parts[2])
|
||||
|
||||
await state.update_data(tariff_id=tariff_id, devices_override=devices)
|
||||
|
||||
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||
tariff = result.scalar_one_or_none()
|
||||
if not tariff:
|
||||
await callback.message.edit_text("❌ Тариф не найден.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
overrides = getattr(tariff, "device_overrides", None) or {}
|
||||
key = str(devices)
|
||||
formula_extra = calculate_device_formula_extra(tariff, devices)
|
||||
override_extra = overrides.get(key)
|
||||
if override_extra is not None:
|
||||
effective_extra = int(override_extra)
|
||||
note = "индивидуальная доплата"
|
||||
else:
|
||||
effective_extra = formula_extra
|
||||
note = "доплата по базовому шагу"
|
||||
|
||||
if devices == 0:
|
||||
label = "безлимитное количество устройств"
|
||||
else:
|
||||
label = f"{devices} устройств"
|
||||
|
||||
text = (
|
||||
f"📊 {label}.\n\n"
|
||||
f"Текущая доплата для этого варианта: <b>{effective_extra}₽</b> ({note}).\n\n"
|
||||
"Введите новую <u>доплату за устройства</u> для этого варианта в рублях.\n"
|
||||
"Отправьте <code>0</code>, чтобы вернуть расчёт по базовому шагу."
|
||||
)
|
||||
await callback.message.edit_text(text=text, reply_markup=build_cancel_config_kb(tariff_id))
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
F.data.startswith("cfg_dev_over_clear|"), TariffConfigState.entering_device_overrides, IsAdminFilter()
|
||||
)
|
||||
async def clear_device_overrides(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
tariff_id = int(callback.data.split("|")[1])
|
||||
|
||||
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||
tariff = result.scalar_one_or_none()
|
||||
if not tariff:
|
||||
await callback.message.edit_text("❌ Тариф не найден.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
tariff.device_overrides = None
|
||||
tariff.updated_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
|
||||
text, markup = build_device_overrides_screen(tariff)
|
||||
await callback.message.edit_text(text=text, reply_markup=markup)
|
||||
|
||||
|
||||
@router.message(TariffConfigState.entering_device_overrides, IsAdminFilter())
|
||||
async def save_device_override_price(message: Message, state: FSMContext, session: AsyncSession):
|
||||
data = await state.get_data()
|
||||
tariff_id = data.get("tariff_id")
|
||||
devices = data.get("devices_override")
|
||||
|
||||
if not tariff_id or devices is None:
|
||||
await message.answer("Сначала выберите вариант устройств из списка.")
|
||||
return
|
||||
|
||||
try:
|
||||
extra_price = int(message.text.strip())
|
||||
if extra_price < 0:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
await message.answer(
|
||||
"❌ Некорректное значение. Введите целое число 0 или больше.",
|
||||
reply_markup=build_cancel_config_kb(int(tariff_id)),
|
||||
)
|
||||
return
|
||||
|
||||
result = await session.execute(select(Tariff).where(Tariff.id == int(tariff_id)))
|
||||
tariff = result.scalar_one_or_none()
|
||||
if not tariff:
|
||||
await message.answer("❌ Тариф не найден.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
overrides = getattr(tariff, "device_overrides", None) or {}
|
||||
key = str(int(devices))
|
||||
|
||||
if extra_price == 0:
|
||||
overrides.pop(key, None)
|
||||
else:
|
||||
overrides[key] = extra_price
|
||||
|
||||
tariff.device_overrides = overrides or None
|
||||
tariff.updated_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
|
||||
await state.update_data(devices_override=None)
|
||||
|
||||
text, markup = build_device_overrides_screen(tariff)
|
||||
await message.answer(text=text, reply_markup=markup)
|
||||
@@ -0,0 +1,144 @@
|
||||
import re
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Tariff
|
||||
from filters.admin import IsAdminFilter
|
||||
|
||||
from .. import router
|
||||
from .common import (
|
||||
TariffConfigState,
|
||||
build_cancel_config_kb,
|
||||
build_config_menu_kb,
|
||||
build_config_summary_text,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("cfg_edit_devices|"), TariffConfigState.choosing_section, IsAdminFilter())
|
||||
async def ask_devices_config(callback: CallbackQuery, state: FSMContext):
|
||||
tariff_id = int(callback.data.split("|")[1])
|
||||
await state.set_state(TariffConfigState.entering_devices)
|
||||
await state.update_data(tariff_id=tariff_id)
|
||||
|
||||
text = (
|
||||
"📱 Настройка вариантов устройств.\n\n"
|
||||
"Введите список вариантов количества устройств через пробел или запятую.\n"
|
||||
"Например: <code>1 3 5</code>\n\n"
|
||||
"Число <code>0</code> можно использовать как вариант безлимита.\n"
|
||||
"Чтобы совсем отключить выбор устройств и использовать только базовый лимит тарифа, отправьте единичный <code>0</code>."
|
||||
)
|
||||
await callback.message.edit_text(text=text, reply_markup=build_cancel_config_kb(tariff_id))
|
||||
|
||||
|
||||
@router.message(TariffConfigState.entering_devices, IsAdminFilter())
|
||||
async def save_devices_config(message: Message, state: FSMContext, session: AsyncSession):
|
||||
data = await state.get_data()
|
||||
tariff_id = data["tariff_id"]
|
||||
raw_text = message.text.strip()
|
||||
|
||||
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||
tariff = result.scalar_one_or_none()
|
||||
if not tariff:
|
||||
await message.answer("❌ Тариф не найден.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
if raw_text == "0":
|
||||
tariff.device_options = None
|
||||
else:
|
||||
try:
|
||||
parts = [p for p in re.split(r"[,\s]+", raw_text) if p.strip()]
|
||||
if not parts:
|
||||
raise ValueError
|
||||
values: list[int] = []
|
||||
for part in parts:
|
||||
v = int(part)
|
||||
if v < 0:
|
||||
raise ValueError
|
||||
values.append(v)
|
||||
values = sorted(set(values))
|
||||
tariff.device_options = values
|
||||
except Exception:
|
||||
await message.answer(
|
||||
"❌ Некорректные значения. Введите положительные числа или 0 через пробел или запятую,\n"
|
||||
"например: <code>1 3 5</code>, <code>0 1 3 5</code> (0 как безлимит)\n"
|
||||
"или <code>0</code> для отключения выбора устройств.",
|
||||
reply_markup=build_cancel_config_kb(tariff_id),
|
||||
)
|
||||
return
|
||||
|
||||
tariff.updated_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
|
||||
await state.set_state(TariffConfigState.choosing_section)
|
||||
|
||||
text = build_config_summary_text(tariff)
|
||||
await message.answer(text=text, reply_markup=build_config_menu_kb(tariff_id))
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("cfg_edit_traffic|"), TariffConfigState.choosing_section, IsAdminFilter())
|
||||
async def ask_traffic_config(callback: CallbackQuery, state: FSMContext):
|
||||
tariff_id = int(callback.data.split("|")[1])
|
||||
await state.set_state(TariffConfigState.entering_traffic)
|
||||
await state.update_data(tariff_id=tariff_id)
|
||||
|
||||
text = (
|
||||
"📦 Настройка вариантов трафика.\n\n"
|
||||
"Введите список лимитов трафика в ГБ через пробел или запятую.\n"
|
||||
"Например: <code>100 200 500</code>\n\n"
|
||||
"Число <code>0</code> можно использовать как вариант безлимита.\n"
|
||||
"Чтобы совсем отключить выбор трафика и использовать только базовый лимит тарифа, отправьте единичный <code>0</code>."
|
||||
)
|
||||
await callback.message.edit_text(text=text, reply_markup=build_cancel_config_kb(tariff_id))
|
||||
|
||||
|
||||
@router.message(TariffConfigState.entering_traffic, IsAdminFilter())
|
||||
async def save_traffic_config(message: Message, state: FSMContext, session: AsyncSession):
|
||||
data = await state.get_data()
|
||||
tariff_id = data["tariff_id"]
|
||||
raw_text = message.text.strip()
|
||||
|
||||
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||
tariff = result.scalar_one_or_none()
|
||||
if not tariff:
|
||||
await message.answer("❌ Тариф не найден.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
if raw_text == "0":
|
||||
tariff.traffic_options_gb = None
|
||||
else:
|
||||
try:
|
||||
parts = [p for p in re.split(r"[,\s]+", raw_text) if p.strip()]
|
||||
if not parts:
|
||||
raise ValueError
|
||||
values: list[int] = []
|
||||
for part in parts:
|
||||
v = int(part)
|
||||
if v < 0:
|
||||
raise ValueError
|
||||
values.append(v)
|
||||
values = sorted(set(values))
|
||||
tariff.traffic_options_gb = values
|
||||
except Exception:
|
||||
await message.answer(
|
||||
"❌ Некорректные значения. Введите числа 0 и больше через пробел или запятую,\n"
|
||||
"например: <code>100 200 500</code>.\n"
|
||||
"0 можно использовать как вариант безлимита или отправить единственный 0 для отключения выбора.",
|
||||
reply_markup=build_cancel_config_kb(tariff_id),
|
||||
)
|
||||
return
|
||||
|
||||
tariff.updated_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
|
||||
await state.set_state(TariffConfigState.choosing_section)
|
||||
|
||||
text = build_config_summary_text(tariff)
|
||||
await message.answer(text=text, reply_markup=build_config_menu_kb(tariff_id))
|
||||
@@ -0,0 +1,194 @@
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Tariff
|
||||
from filters.admin import IsAdminFilter
|
||||
|
||||
from .. import router
|
||||
from .common import (
|
||||
TariffConfigState,
|
||||
build_cancel_config_kb,
|
||||
build_config_menu_kb,
|
||||
build_config_summary_text,
|
||||
build_traffic_overrides_screen,
|
||||
calculate_traffic_formula_extra,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("cfg_edit_traffic_step|"), TariffConfigState.choosing_section, IsAdminFilter())
|
||||
async def ask_traffic_step(callback: CallbackQuery, state: FSMContext):
|
||||
tariff_id = int(callback.data.split("|")[1])
|
||||
await state.set_state(TariffConfigState.entering_traffic_step)
|
||||
await state.update_data(tariff_id=tariff_id)
|
||||
|
||||
text = (
|
||||
"💰 Базовый шаг доплаты за трафик.\n\n"
|
||||
"Введите цену в рублях за 1 ГБ сверх базового лимита.\n"
|
||||
"Например: <code>5</code>\n\n"
|
||||
"Чтобы выключить автоматическую доплату за трафик, отправьте <code>0</code>."
|
||||
)
|
||||
await callback.message.edit_text(text=text, reply_markup=build_cancel_config_kb(tariff_id))
|
||||
|
||||
|
||||
@router.message(TariffConfigState.entering_traffic_step, IsAdminFilter())
|
||||
async def save_traffic_step(message: Message, state: FSMContext, session: AsyncSession):
|
||||
data = await state.get_data()
|
||||
tariff_id = data["tariff_id"]
|
||||
|
||||
try:
|
||||
price = int(message.text.strip())
|
||||
if price < 0:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
await message.answer(
|
||||
"❌ Некорректное значение. Введите целое число 0 или больше.",
|
||||
reply_markup=build_cancel_config_kb(tariff_id),
|
||||
)
|
||||
return
|
||||
|
||||
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||
tariff = result.scalar_one_or_none()
|
||||
if not tariff:
|
||||
await message.answer("❌ Тариф не найден.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
tariff.traffic_step_rub = price
|
||||
tariff.updated_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
|
||||
await state.set_state(TariffConfigState.choosing_section)
|
||||
|
||||
text = build_config_summary_text(tariff)
|
||||
await message.answer(text=text, reply_markup=build_config_menu_kb(tariff_id))
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("cfg_edit_traffic_over|"), TariffConfigState.choosing_section, IsAdminFilter())
|
||||
async def open_traffic_overrides_menu(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
tariff_id = int(callback.data.split("|")[1])
|
||||
|
||||
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||
tariff = result.scalar_one_or_none()
|
||||
if not tariff:
|
||||
await callback.message.edit_text("❌ Тариф не найден.")
|
||||
return
|
||||
|
||||
await state.set_state(TariffConfigState.entering_traffic_overrides)
|
||||
await state.update_data(tariff_id=tariff_id, traffic_override_gb=None)
|
||||
|
||||
text, markup = build_traffic_overrides_screen(tariff)
|
||||
await callback.message.edit_text(text=text, reply_markup=markup)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
F.data.startswith("cfg_trf_over_item|"), TariffConfigState.entering_traffic_overrides, IsAdminFilter()
|
||||
)
|
||||
async def choose_traffic_override_option(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
parts = callback.data.split("|")
|
||||
tariff_id = int(parts[1])
|
||||
gb_value = int(parts[2])
|
||||
|
||||
await state.update_data(tariff_id=tariff_id, traffic_override_gb=gb_value)
|
||||
|
||||
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||
tariff = result.scalar_one_or_none()
|
||||
if not tariff:
|
||||
await callback.message.edit_text("❌ Тариф не найден.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
overrides = getattr(tariff, "traffic_overrides", None) or {}
|
||||
key = str(gb_value)
|
||||
formula_extra = calculate_traffic_formula_extra(tariff, gb_value)
|
||||
override_extra = overrides.get(key)
|
||||
if override_extra is not None:
|
||||
effective_extra = int(override_extra)
|
||||
note = "индивидуальная доплата"
|
||||
else:
|
||||
effective_extra = formula_extra
|
||||
note = "доплата по базовому шагу"
|
||||
|
||||
if gb_value == 0:
|
||||
label = "безлимитный трафик"
|
||||
else:
|
||||
label = f"лимит {gb_value} ГБ"
|
||||
|
||||
text = (
|
||||
f"📊 {label}.\n\n"
|
||||
f"Текущая доплата для этого варианта: <b>{effective_extra}₽</b> ({note}).\n\n"
|
||||
"Введите новую <u>доплату за трафик</u> для этого лимита в рублях.\n"
|
||||
"Отправьте <code>0</code>, чтобы вернуть расчёт по базовому шагу."
|
||||
)
|
||||
await callback.message.edit_text(text=text, reply_markup=build_cancel_config_kb(tariff_id))
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
F.data.startswith("cfg_trf_over_clear|"), TariffConfigState.entering_traffic_overrides, IsAdminFilter()
|
||||
)
|
||||
async def clear_traffic_overrides(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
tariff_id = int(callback.data.split("|")[1])
|
||||
|
||||
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||
tariff = result.scalar_one_or_none()
|
||||
if not tariff:
|
||||
await callback.message.edit_text("❌ Тариф не найден.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
tariff.traffic_overrides = None
|
||||
tariff.updated_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
|
||||
text, markup = build_traffic_overrides_screen(tariff)
|
||||
await callback.message.edit_text(text=text, reply_markup=markup)
|
||||
|
||||
|
||||
@router.message(TariffConfigState.entering_traffic_overrides, IsAdminFilter())
|
||||
async def save_traffic_override_price(message: Message, state: FSMContext, session: AsyncSession):
|
||||
data = await state.get_data()
|
||||
tariff_id = data.get("tariff_id")
|
||||
gb_value = data.get("traffic_override_gb")
|
||||
|
||||
if not tariff_id or gb_value is None:
|
||||
await message.answer("Сначала выберите вариант лимита трафика из списка.")
|
||||
return
|
||||
|
||||
try:
|
||||
extra_price = int(message.text.strip())
|
||||
if extra_price < 0:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
await message.answer(
|
||||
"❌ Некорректное значение. Введите целое число 0 или больше.",
|
||||
reply_markup=build_cancel_config_kb(int(tariff_id)),
|
||||
)
|
||||
return
|
||||
|
||||
result = await session.execute(select(Tariff).where(Tariff.id == int(tariff_id)))
|
||||
tariff = result.scalar_one_or_none()
|
||||
if not tariff:
|
||||
await message.answer("❌ Тариф не найден.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
overrides = getattr(tariff, "traffic_overrides", None) or {}
|
||||
key = str(int(gb_value))
|
||||
|
||||
if extra_price == 0:
|
||||
overrides.pop(key, None)
|
||||
else:
|
||||
overrides[key] = extra_price
|
||||
|
||||
tariff.traffic_overrides = overrides or None
|
||||
tariff.updated_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
|
||||
await state.update_data(traffic_override_gb=None)
|
||||
|
||||
text, markup = build_traffic_overrides_screen(tariff)
|
||||
await message.answer(text=text, reply_markup=markup)
|
||||
@@ -587,3 +587,24 @@ async def start_tariff_creation_existing_group(
|
||||
f"📦 Добавление нового тарифа в группу <code>{group_code}</code>\n\n📝 Введите <b>название тарифа</b>:",
|
||||
reply_markup=build_cancel_kb(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("toggle_configurable|"), IsAdminFilter())
|
||||
async def toggle_tariff_configurable(callback: CallbackQuery, session: AsyncSession):
|
||||
tariff_id = int(callback.data.split("|")[1])
|
||||
|
||||
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||
tariff = result.scalar_one_or_none()
|
||||
|
||||
if not tariff:
|
||||
await callback.message.edit_text("❌ Тариф не найден.")
|
||||
return
|
||||
|
||||
current = bool(tariff.configurable)
|
||||
tariff.configurable = not current
|
||||
tariff.updated_at = datetime.utcnow()
|
||||
|
||||
await session.commit()
|
||||
|
||||
text, markup = render_tariff_card(tariff)
|
||||
await callback.message.edit_text(text=text, reply_markup=markup)
|
||||
|
||||
@@ -39,6 +39,8 @@ def render_tariff_card(tariff: Tariff) -> tuple[str, InlineKeyboardMarkup]:
|
||||
device_text = f"{tariff.device_limit}" if tariff.device_limit is not None else "Безлимит"
|
||||
sort_order = getattr(tariff, "sort_order", 1)
|
||||
vless_text = "Да" if getattr(tariff, "vless", False) else "Нет"
|
||||
configurable = bool(getattr(tariff, "configurable", False))
|
||||
configurable_text = "Включен" if configurable else "Выключен"
|
||||
|
||||
text = (
|
||||
f"<b>📄 Тариф: {tariff.name}</b>\n\n"
|
||||
@@ -48,8 +50,9 @@ def render_tariff_card(tariff: Tariff) -> tuple[str, InlineKeyboardMarkup]:
|
||||
f"📦 Трафик: <b>{traffic_text}</b>\n"
|
||||
f"📱 Устройств: <b>{device_text}</b>\n"
|
||||
f"🔗 VLESS: <b>{vless_text}</b>\n"
|
||||
f"⚙️ Конфигуратор: <b>{configurable_text}</b>\n"
|
||||
f"🔢 Позиция: <b>{sort_order}</b>\n"
|
||||
f"{'✅ Активен' if tariff.is_active else '⛔ Отключен'}"
|
||||
)
|
||||
|
||||
return text, build_single_tariff_kb(tariff.id, tariff.group_code)
|
||||
return text, build_single_tariff_kb(tariff.id, tariff.group_code, configurable=configurable)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from aiogram import Router
|
||||
|
||||
from . import users_bans, users_hwid, users_keys, users_manage, users_balance
|
||||
from . import users_balance, users_bans, users_hwid, users_keys, users_manage
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
@@ -246,7 +246,9 @@ def build_user_key_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
|
||||
def build_key_edit_kb(key_details: dict, email: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
is_frozen = key_details.get("is_frozen") if isinstance(key_details, dict) else getattr(key_details, "is_frozen", False)
|
||||
is_frozen = (
|
||||
key_details.get("is_frozen") if isinstance(key_details, dict) else getattr(key_details, "is_frozen", False)
|
||||
)
|
||||
|
||||
builder.button(
|
||||
text="⏳ Время истечения",
|
||||
@@ -287,9 +289,7 @@ def build_key_edit_kb(key_details: dict, email: str) -> InlineKeyboardMarkup:
|
||||
else:
|
||||
builder.button(
|
||||
text=FREEZE,
|
||||
callback_data=AdminUserEditorCallback(
|
||||
action="users_freeze", data=email, tg_id=key_details["tg_id"]
|
||||
).pack(),
|
||||
callback_data=AdminUserEditorCallback(action="users_freeze", data=email, tg_id=key_details["tg_id"]).pack(),
|
||||
)
|
||||
|
||||
if HWID_RESET_BUTTON:
|
||||
|
||||
@@ -26,10 +26,7 @@ def format_admin_operation(amount: float, created_at: datetime) -> str:
|
||||
date_str = created_at.strftime("%Y-%m-%d %H:%M:%S")
|
||||
sign = "+" if amount > 0 else "-" if amount < 0 else ""
|
||||
abs_amount = abs(amount)
|
||||
return (
|
||||
f"\n<blockquote>Админ {sign}{abs_amount}Р"
|
||||
f"\n⏳ Дата: {date_str}</blockquote>"
|
||||
)
|
||||
return f"\n<blockquote>Админ {sign}{abs_amount}Р\n⏳ Дата: {date_str}</blockquote>"
|
||||
|
||||
|
||||
def format_user_payment(amount: float, created_at: datetime, payment_system: str, status: str) -> str:
|
||||
@@ -37,9 +34,7 @@ def format_user_payment(amount: float, created_at: datetime, payment_system: str
|
||||
abs_amount = abs(amount)
|
||||
system_name = payment_system or "Неизвестно"
|
||||
return (
|
||||
f"\n<blockquote>💸 Сумма: {abs_amount} | {system_name}"
|
||||
f"\n📌 Статус: {status}"
|
||||
f"\n⏳ Дата: {date_str}</blockquote>"
|
||||
f"\n<blockquote>💸 Сумма: {abs_amount} | {system_name}\n📌 Статус: {status}\n⏳ Дата: {date_str}</blockquote>"
|
||||
)
|
||||
|
||||
|
||||
@@ -68,21 +63,14 @@ async def handle_balance_change(
|
||||
|
||||
stmt_user = (
|
||||
select(Payment.amount, Payment.created_at, Payment.payment_system, Payment.status)
|
||||
.where(
|
||||
Payment.tg_id == tg_id,
|
||||
Payment.payment_system != "admin"
|
||||
)
|
||||
.where(Payment.tg_id == tg_id, Payment.payment_system != "admin")
|
||||
.order_by(Payment.created_at.desc())
|
||||
.limit(5)
|
||||
)
|
||||
result_user = await session.execute(stmt_user)
|
||||
user_records = result_user.all()
|
||||
|
||||
text = (
|
||||
f"<b>💵 Изменение баланса</b>"
|
||||
f"\n\n🆔 ID: <b>{tg_id}</b>"
|
||||
f"\n💰 Баланс: <b>{balance}Р</b>"
|
||||
)
|
||||
text = f"<b>💵 Изменение баланса</b>\n\n🆔 ID: <b>{tg_id}</b>\n💰 Баланс: <b>{balance}Р</b>"
|
||||
|
||||
text += "\n\n<b>📊 Операции админа (5):</b>"
|
||||
if admin_records:
|
||||
|
||||
@@ -41,7 +41,6 @@ from handlers.utils import generate_random_email, handle_error
|
||||
from hooks.hook_buttons import insert_hook_buttons
|
||||
from hooks.processors import process_admin_key_edit_menu
|
||||
from logger import logger
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn, build_admin_back_kb
|
||||
from .keyboard import (
|
||||
@@ -1026,8 +1025,7 @@ async def handle_admin_freeze_subscription(
|
||||
result = await toggle_client_on_cluster(cluster_id, email, client_id, enable=False, session=session)
|
||||
if result["status"] != "success":
|
||||
text_error = (
|
||||
"Произошла ошибка при заморозке подписки.\n"
|
||||
f"Детали: {result.get('error') or result.get('results')}"
|
||||
f"Произошла ошибка при заморозке подписки.\nДетали: {result.get('error') or result.get('results')}"
|
||||
)
|
||||
await callback_query.message.edit_text(
|
||||
text_error,
|
||||
@@ -1082,8 +1080,7 @@ async def handle_admin_unfreeze_subscription(
|
||||
result = await toggle_client_on_cluster(cluster_id, email, client_id, enable=True, session=session)
|
||||
if result["status"] != "success":
|
||||
text_error = (
|
||||
"Произошла ошибка при включении подписки.\n"
|
||||
f"Детали: {result.get('error') or result.get('results')}"
|
||||
f"Произошла ошибка при включении подписки.\nДетали: {result.get('error') or result.get('results')}"
|
||||
)
|
||||
await callback_query.message.edit_text(
|
||||
text_error,
|
||||
|
||||
@@ -21,6 +21,11 @@ TRIAL_SUB = "🎁 Пробная подписка"
|
||||
MY_SUB = "🔐 Моя подписка"
|
||||
RENEW_SUB = "🔄 Обновить подписку"
|
||||
|
||||
ADDONS_BUTTON_DEVICES = "➕ Добавить устройства"
|
||||
ADDONS_BUTTON_TRAFFIC = "➕ Добавить трафик"
|
||||
ADDONS_BUTTON_DEVICES_TRAFFIC = "➕ Добавить устройства/трафик"
|
||||
|
||||
|
||||
# Меню Оплат и баланса
|
||||
|
||||
BALANCE_HISTORY = "📊 История пополнения"
|
||||
@@ -86,6 +91,11 @@ TV_INSTRUCTIONS = "📖 Полная инструкция"
|
||||
HWID_BUTTON = "♻️ Сбросить привязку"
|
||||
ROUTER_BUTTON = "Подключить роутер"
|
||||
|
||||
# Кнопки конфигуратора
|
||||
DOWNGRADE_ADDON_BUTTON_TEXT = "Понизить условия"
|
||||
DOWNGRADE_CONFIRM_BUTTON_TEXT = "Подтвердить понижение"
|
||||
CONFIG_PAY_BUTTON_TEXT = "Оплатить {amount}"
|
||||
CONFIRM_ADDON_BUTTON_TEXT = "Подтвердить доплату {amount}"
|
||||
|
||||
# Кнопки касс
|
||||
|
||||
|
||||
@@ -7,18 +7,18 @@ from aiogram import F, Router
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from core.bootstrap import MODES_CONFIG
|
||||
from config import (
|
||||
CONNECT_MACOS,
|
||||
CONNECT_WINDOWS,
|
||||
DOWNLOAD_MACOS,
|
||||
DOWNLOAD_PC,
|
||||
HAPP_CRYPTOLINK,
|
||||
REMNAWAVE_WEBAPP,
|
||||
SUPPORT_CHAT_URL,
|
||||
WEBHOOK_HOST,
|
||||
REMNAWAVE_WEBAPP,
|
||||
HAPP_CRYPTOLINK,
|
||||
)
|
||||
from database import get_subscription_link, get_key_details
|
||||
from core.bootstrap import MODES_CONFIG
|
||||
from database import get_key_details, get_subscription_link
|
||||
from handlers.buttons import (
|
||||
BACK,
|
||||
CONNECT_MACOS_BUTTON,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from math import ceil
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
@@ -10,20 +9,18 @@ from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import (
|
||||
DISCOUNT_ACTIVE_HOURS,
|
||||
NOTIFY_EXTRA_DAYS,
|
||||
TRIAL_TIME_DISABLE,
|
||||
USE_COUNTRY_SELECTION,
|
||||
USE_NEW_PAYMENT_FLOW,
|
||||
)
|
||||
from core.bootstrap import MODES_CONFIG, NOTIFICATIONS_CONFIG
|
||||
from database import (
|
||||
add_user,
|
||||
check_user_exists,
|
||||
get_balance,
|
||||
get_tariff_by_id,
|
||||
get_tariffs_for_cluster,
|
||||
get_trial,
|
||||
)
|
||||
@@ -31,32 +28,28 @@ from database.models import Admin
|
||||
from database.notifications import check_hot_lead_discount
|
||||
from database.tariffs import create_subgroup_hash, find_subgroup_by_hash, get_tariffs
|
||||
from handlers.admin.panel.keyboard import AdminPanelCallback
|
||||
from handlers.buttons import MAIN_MENU, PAYMENT
|
||||
from handlers.buttons import MAIN_MENU
|
||||
from handlers.payments.currency_rates import format_for_user
|
||||
from handlers.payments.fast_payment_flow import try_fast_payment_flow
|
||||
from handlers.texts import (
|
||||
CREATING_CONNECTION_MSG,
|
||||
DISCOUNT_OFFER_MESSAGE,
|
||||
DISCOUNT_OFFER_STEP2,
|
||||
DISCOUNT_OFFER_STEP3,
|
||||
INSUFFICIENT_FUNDS_MSG,
|
||||
SELECT_TARIFF_PLAN_MSG,
|
||||
)
|
||||
from handlers.utils import edit_or_send_message, format_discount_time_left, get_least_loaded_cluster
|
||||
from hooks.hook_buttons import insert_hook_buttons
|
||||
from hooks.processors import (
|
||||
process_check_discount_validity,
|
||||
process_purchase_tariff_group_override,
|
||||
process_tariff_menu,
|
||||
)
|
||||
from logger import logger
|
||||
|
||||
from .key_cluster_mode import key_cluster_mode
|
||||
from .key_country_mode import key_country_mode
|
||||
from .key_mode.key_cluster_mode import key_cluster_mode
|
||||
from .key_mode.key_country_mode import key_country_mode
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
|
||||
|
||||
@@ -67,12 +60,16 @@ class Form(FSMContext):
|
||||
@router.callback_query(F.data == "create_key")
|
||||
@router.callback_query(F.data == "buy")
|
||||
@router.message(F.text == "/buy")
|
||||
async def confirm_create_new_key(callback_query_or_message: CallbackQuery | Message, state: FSMContext, session: Any):
|
||||
async def confirm_create_new_key(
|
||||
callback_query_or_message: CallbackQuery | Message,
|
||||
state: FSMContext,
|
||||
session: AsyncSession,
|
||||
):
|
||||
if isinstance(callback_query_or_message, CallbackQuery):
|
||||
tg_id = callback_query_or_message.message.chat.id
|
||||
message_or_query = callback_query_or_message
|
||||
tg_id = callback_query_or_message.from_user.id
|
||||
message_or_query: Message | CallbackQuery = callback_query_or_message
|
||||
else:
|
||||
tg_id = callback_query_or_message.chat.id
|
||||
tg_id = callback_query_or_message.from_user.id
|
||||
message_or_query = callback_query_or_message
|
||||
|
||||
await handle_key_creation(tg_id, state, session, message_or_query)
|
||||
@@ -81,7 +78,7 @@ async def confirm_create_new_key(callback_query_or_message: CallbackQuery | Mess
|
||||
async def handle_key_creation(
|
||||
tg_id: int,
|
||||
state: FSMContext,
|
||||
session: Any,
|
||||
session: AsyncSession,
|
||||
message_or_query: Message | CallbackQuery,
|
||||
):
|
||||
state_data = await state.get_data()
|
||||
@@ -91,6 +88,9 @@ async def handle_key_creation(
|
||||
|
||||
await state.update_data(key_creation_in_progress=True)
|
||||
|
||||
target_message = message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
|
||||
language_code = message_or_query.from_user.language_code
|
||||
|
||||
try:
|
||||
current_time = datetime.now(moscow_tz)
|
||||
|
||||
@@ -100,16 +100,12 @@ async def handle_key_creation(
|
||||
if trial_status in [0, -1]:
|
||||
trial_tariffs = await get_tariffs(session, group_code="trial")
|
||||
if not trial_tariffs:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
await edit_or_send_message(
|
||||
target_message=(
|
||||
message_or_query.message
|
||||
if isinstance(message_or_query, CallbackQuery)
|
||||
else message_or_query
|
||||
),
|
||||
target_message=target_message,
|
||||
text="❌ Пробная подписка временно недоступна.",
|
||||
reply_markup=InlineKeyboardBuilder()
|
||||
.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
.as_markup(),
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
@@ -123,42 +119,40 @@ async def handle_key_creation(
|
||||
logger.info(f"[Trial] Доступен {total_days}-дневный триал для пользователя {tg_id}")
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=(
|
||||
message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
|
||||
),
|
||||
target_message=target_message,
|
||||
text=CREATING_CONNECTION_MSG,
|
||||
reply_markup=None,
|
||||
)
|
||||
|
||||
await state.update_data(is_trial=True, plan=trial_tariff["id"])
|
||||
await create_key(tg_id, expiry_time, state, session, message_or_query, plan=trial_tariff["id"])
|
||||
await create_key(
|
||||
tg_id=tg_id,
|
||||
expiry_time=expiry_time,
|
||||
state=state,
|
||||
session=session,
|
||||
message_or_query=message_or_query,
|
||||
old_key_name=None,
|
||||
plan=trial_tariff["id"],
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
cluster_name = await get_least_loaded_cluster(session)
|
||||
except ValueError as e:
|
||||
logger.error(f"Нет доступных кластеров: {e}")
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
await edit_or_send_message(
|
||||
target_message=(
|
||||
message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
|
||||
),
|
||||
target_message=target_message,
|
||||
text=str(e),
|
||||
reply_markup=InlineKeyboardBuilder()
|
||||
.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
.as_markup(),
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
tariffs = await get_tariffs_for_cluster(session, cluster_name)
|
||||
|
||||
language_code = (
|
||||
message_or_query.from_user.language_code
|
||||
if not isinstance(message_or_query, CallbackQuery)
|
||||
else message_or_query.from_user.language_code
|
||||
)
|
||||
|
||||
discount_info = None
|
||||
subgroup_weights = {}
|
||||
discount_info: dict[str, Any] | None = None
|
||||
subgroup_weights: dict[str, int] = {}
|
||||
|
||||
if tariffs:
|
||||
group_code = tariffs[0].get("group_code")
|
||||
@@ -183,7 +177,11 @@ async def handle_key_creation(
|
||||
if override_result.get("discount_info"):
|
||||
await state.update_data(discount_info=override_result["discount_info"])
|
||||
|
||||
tariffs_data = await get_tariffs(session, group_code=group_code, with_subgroup_weights=True)
|
||||
tariffs_data = await get_tariffs(
|
||||
session,
|
||||
group_code=group_code,
|
||||
with_subgroup_weights=True,
|
||||
)
|
||||
tariffs = [t for t in tariffs_data["tariffs"] if t.get("is_active")]
|
||||
subgroup_weights = tariffs_data["subgroup_weights"]
|
||||
|
||||
@@ -195,17 +193,18 @@ async def handle_key_creation(
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔗 Привязать тариф", callback_data=AdminPanelCallback(action="clusters").pack()
|
||||
text="🔗 Привязать тариф",
|
||||
callback_data=AdminPanelCallback(action="clusters").pack(),
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
text = (
|
||||
f"🚫 <b>Невозможно создать подписку</b>\n\n"
|
||||
f"📊 <b>Информация о кластере:</b>\n<blockquote>"
|
||||
"🚫 <b>Невозможно создать подписку</b>\n\n"
|
||||
"📊 <b>Информация о кластере:</b>\n<blockquote>"
|
||||
f"🌐 <b>Кластер:</b> <code>{cluster_name}</code>\n"
|
||||
f"⚠️ <b>Статус:</b> Нет привязанного тарифа\n</blockquote>"
|
||||
f"💡 <b>Привяжите тариф к кластеру</b>"
|
||||
"⚠️ <b>Статус:</b> Нет привязанного тарифа\n</blockquote>"
|
||||
"💡 <b>Привяжите тариф к кластеру</b>"
|
||||
)
|
||||
else:
|
||||
builder = InlineKeyboardBuilder()
|
||||
@@ -213,9 +212,7 @@ async def handle_key_creation(
|
||||
text = "❌ Нет доступных тарифов для выбора."
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=(
|
||||
message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
|
||||
),
|
||||
target_message=target_message,
|
||||
text=text,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
@@ -224,55 +221,53 @@ async def handle_key_creation(
|
||||
group_code = tariffs[0].get("group_code") if tariffs else None
|
||||
if not group_code:
|
||||
await edit_or_send_message(
|
||||
target_message=(
|
||||
message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
|
||||
),
|
||||
target_message=target_message,
|
||||
text="❌ Не удалось определить группу тарифов.",
|
||||
reply_markup=None,
|
||||
)
|
||||
return
|
||||
|
||||
grouped_tariffs = defaultdict(list)
|
||||
for t in tariffs:
|
||||
subgroup = t.get("subgroup_title")
|
||||
grouped_tariffs[subgroup].append(t)
|
||||
grouped_tariffs: dict[str | None, list[dict[str, Any]]] = defaultdict(list)
|
||||
for tariff in tariffs:
|
||||
subgroup = tariff.get("subgroup_title")
|
||||
grouped_tariffs[subgroup].append(tariff)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for t in grouped_tariffs.get(None, []):
|
||||
price_txt = await format_for_user(session, tg_id, t.get("price_rub", 0), language_code)
|
||||
for tariff in grouped_tariffs.get(None, []):
|
||||
price_text = await format_for_user(session, tg_id, tariff.get("price_rub", 0), language_code)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"{t['name']} — {price_txt}",
|
||||
callback_data=f"select_tariff_plan|{t['id']}",
|
||||
text=f"{tariff['name']} — {price_text}",
|
||||
callback_data=f"select_tariff_plan|{tariff['id']}",
|
||||
)
|
||||
)
|
||||
|
||||
sorted_subgroups = sorted(
|
||||
[k for k in grouped_tariffs if k],
|
||||
key=lambda x: (subgroup_weights.get(x, 999999) if subgroup_weights else 999999, x),
|
||||
[key for key in grouped_tariffs if key],
|
||||
key=lambda title: (subgroup_weights.get(title, 999999) if subgroup_weights else 999999, title),
|
||||
)
|
||||
|
||||
for subgroup in sorted_subgroups:
|
||||
subgroup_hash = create_subgroup_hash(subgroup, group_code)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"{subgroup}",
|
||||
text=subgroup,
|
||||
callback_data=f"tariff_subgroup_user|{subgroup_hash}",
|
||||
)
|
||||
)
|
||||
|
||||
tariff_menu_buttons = await process_tariff_menu(
|
||||
group_code=group_code, cluster_name=cluster_name, tg_id=tg_id, session=session
|
||||
group_code=group_code,
|
||||
cluster_name=cluster_name,
|
||||
tg_id=tg_id,
|
||||
session=session,
|
||||
)
|
||||
builder = insert_hook_buttons(builder, tariff_menu_buttons)
|
||||
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
target_message = message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
|
||||
|
||||
discount_message = ""
|
||||
|
||||
if discount_info and discount_info.get("available"):
|
||||
offer_text = DISCOUNT_OFFER_STEP2 if discount_info["type"] == "hot_lead_step_2" else DISCOUNT_OFFER_STEP3
|
||||
expires_at = discount_info["expires_at"]
|
||||
@@ -289,7 +284,11 @@ async def handle_key_creation(
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
await state.update_data(tg_id=tg_id, cluster_name=cluster_name, group_code=group_code)
|
||||
await state.update_data(
|
||||
tg_id=tg_id,
|
||||
cluster_name=cluster_name,
|
||||
group_code=group_code,
|
||||
)
|
||||
await state.set_state(Form.waiting_for_server_selection)
|
||||
|
||||
finally:
|
||||
@@ -297,7 +296,7 @@ async def handle_key_creation(
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("tariff_subgroup_user|"))
|
||||
async def show_tariffs_in_subgroup_user(callback: CallbackQuery, state: FSMContext, session: Any):
|
||||
async def show_tariffs_in_subgroup_user(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
subgroup_hash = callback.data.split("|")[1]
|
||||
data = await state.get_data()
|
||||
cluster_name = data.get("cluster_name")
|
||||
@@ -312,14 +311,16 @@ async def show_tariffs_in_subgroup_user(callback: CallbackQuery, state: FSMConte
|
||||
)
|
||||
return
|
||||
|
||||
tariffs = await get_tariffs_for_cluster(session, cluster_name)
|
||||
filtered = []
|
||||
tariffs_for_cluster = await get_tariffs_for_cluster(session, cluster_name)
|
||||
filtered: list[dict[str, Any]] = []
|
||||
|
||||
if tariffs:
|
||||
group_code = tariffs[0].get("group_code")
|
||||
if tariffs_for_cluster:
|
||||
group_code = tariffs_for_cluster[0].get("group_code")
|
||||
if group_code:
|
||||
tariffs = await get_tariffs(session, group_code=group_code)
|
||||
filtered = [t for t in tariffs if t.get("subgroup_title") == subgroup and t.get("is_active")]
|
||||
filtered = [
|
||||
tariff for tariff in tariffs if tariff.get("subgroup_title") == subgroup and tariff.get("is_active")
|
||||
]
|
||||
|
||||
if not filtered:
|
||||
await edit_or_send_message(
|
||||
@@ -333,12 +334,12 @@ async def show_tariffs_in_subgroup_user(callback: CallbackQuery, state: FSMConte
|
||||
language_code = callback.from_user.language_code
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for t in filtered:
|
||||
price_txt = await format_for_user(session, tg_id, t.get("price_rub", 0), language_code)
|
||||
for tariff in filtered:
|
||||
price_text = await format_for_user(session, tg_id, tariff.get("price_rub", 0), language_code)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"{t['name']} — {price_txt}",
|
||||
callback_data=f"select_tariff_plan|{t['id']}",
|
||||
text=f"{tariff['name']} — {price_text}",
|
||||
callback_data=f"select_tariff_plan|{tariff['id']}",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="back_to_tariff_group_list"))
|
||||
@@ -352,8 +353,7 @@ async def show_tariffs_in_subgroup_user(callback: CallbackQuery, state: FSMConte
|
||||
|
||||
|
||||
@router.callback_query(F.data == "back_to_tariff_group_list")
|
||||
async def back_to_tariff_group_list(callback: CallbackQuery, state: FSMContext, session: Any):
|
||||
await state.get_data()
|
||||
async def back_to_tariff_group_list(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
tg_id = callback.from_user.id
|
||||
await handle_key_creation(
|
||||
tg_id=tg_id,
|
||||
@@ -363,110 +363,18 @@ async def back_to_tariff_group_list(callback: CallbackQuery, state: FSMContext,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("select_tariff_plan|"))
|
||||
async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state: FSMContext):
|
||||
tg_id = callback_query.from_user.id
|
||||
tariff_id = int(callback_query.data.split("|")[1])
|
||||
|
||||
tariff = await get_tariff_by_id(session, tariff_id)
|
||||
if not tariff:
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="❌ Указанный тариф не найден.",
|
||||
)
|
||||
await callback_query.answer()
|
||||
return
|
||||
|
||||
discount_info = await check_hot_lead_discount(session, tg_id)
|
||||
if tariff.get("group_code") in ["discounts", "discounts_max"]:
|
||||
if not discount_info.get("available") or datetime.utcnow() >= discount_info["expires_at"]:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="❌ Скидка недоступна или истекла. Пожалуйста, выберите тариф заново.",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await callback_query.answer()
|
||||
return
|
||||
|
||||
validity_result = await process_check_discount_validity(
|
||||
chat_id=tg_id,
|
||||
admin=False,
|
||||
session=session,
|
||||
tariff_group=tariff.get("group_code"),
|
||||
)
|
||||
if validity_result:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=validity_result["message"],
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await callback_query.answer()
|
||||
return
|
||||
|
||||
duration_days = tariff["duration_days"]
|
||||
price_rub = tariff["price_rub"]
|
||||
|
||||
balance = await get_balance(session, tg_id)
|
||||
|
||||
if balance < price_rub:
|
||||
required_amount = ceil(price_rub - balance)
|
||||
|
||||
if USE_NEW_PAYMENT_FLOW:
|
||||
handled = await try_fast_payment_flow(
|
||||
callback_query,
|
||||
session,
|
||||
state,
|
||||
tg_id=tg_id,
|
||||
temp_key="waiting_for_payment",
|
||||
temp_payload={
|
||||
"tariff_id": tariff_id,
|
||||
"duration_days": duration_days,
|
||||
"required_amount": required_amount,
|
||||
},
|
||||
required_amount=required_amount,
|
||||
)
|
||||
if handled:
|
||||
return
|
||||
|
||||
language_code = getattr(callback_query.from_user, "language_code", None)
|
||||
required_amount_text = await format_for_user(session, tg_id, float(required_amount), language_code)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=INSUFFICIENT_FUNDS_MSG.format(required_amount=required_amount_text),
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="⏳ Подождите...", callback_data="creating_key"))
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=CREATING_CONNECTION_MSG,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await callback_query.answer()
|
||||
|
||||
expiry_time = datetime.now(moscow_tz) + timedelta(days=duration_days)
|
||||
await state.update_data(tariff_id=tariff_id)
|
||||
await create_key(tg_id, expiry_time, state, session, callback_query, plan=tariff_id)
|
||||
|
||||
|
||||
async def create_key(
|
||||
tg_id: int,
|
||||
expiry_time,
|
||||
state,
|
||||
session,
|
||||
message_or_query=None,
|
||||
old_key_name: str = None,
|
||||
plan: int = None,
|
||||
expiry_time: datetime,
|
||||
state: FSMContext | None,
|
||||
session: AsyncSession,
|
||||
message_or_query: Message | CallbackQuery | None = None,
|
||||
old_key_name: str | None = None,
|
||||
plan: int | None = None,
|
||||
selected_duration_days: int | None = None,
|
||||
selected_device_limit: int | None = None,
|
||||
selected_traffic_gb: int | None = None,
|
||||
selected_price_rub: int | None = None,
|
||||
):
|
||||
if not await check_user_exists(session, tg_id):
|
||||
from_user = message_or_query.from_user if isinstance(message_or_query, CallbackQuery | Message) else None
|
||||
@@ -483,6 +391,21 @@ async def create_key(
|
||||
|
||||
use_country_selection = bool(MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION))
|
||||
|
||||
if state and any(
|
||||
value is not None
|
||||
for value in (selected_duration_days, selected_device_limit, selected_traffic_gb, selected_price_rub)
|
||||
):
|
||||
state_data = await state.get_data()
|
||||
if selected_duration_days is not None:
|
||||
state_data["config_selected_duration_days"] = selected_duration_days
|
||||
if selected_device_limit is not None:
|
||||
state_data["config_selected_device_limit"] = selected_device_limit
|
||||
if selected_traffic_gb is not None:
|
||||
state_data["config_selected_traffic_gb"] = selected_traffic_gb
|
||||
if selected_price_rub is not None:
|
||||
state_data["config_selected_price_rub"] = selected_price_rub
|
||||
await state.set_data(state_data)
|
||||
|
||||
if use_country_selection:
|
||||
await key_country_mode(
|
||||
tg_id=tg_id,
|
||||
@@ -492,6 +415,9 @@ async def create_key(
|
||||
message_or_query=message_or_query,
|
||||
old_key_name=old_key_name,
|
||||
plan=plan,
|
||||
selected_device_limit=selected_device_limit,
|
||||
selected_traffic_gb=selected_traffic_gb,
|
||||
selected_price_rub=selected_price_rub,
|
||||
)
|
||||
else:
|
||||
await key_cluster_mode(
|
||||
@@ -501,4 +427,7 @@ async def create_key(
|
||||
session=session,
|
||||
message_or_query=message_or_query,
|
||||
plan=plan,
|
||||
selected_device_limit=selected_device_limit,
|
||||
selected_traffic_gb=selected_traffic_gb,
|
||||
selected_price_rub=selected_price_rub,
|
||||
)
|
||||
@@ -2,12 +2,19 @@ __all__ = ("router",)
|
||||
|
||||
from aiogram import Router
|
||||
|
||||
from ...tariffs.buy.key_tariffs import router as tariffs_router
|
||||
from ..key_create import router as create_router
|
||||
from .key_cluster_mode import router as cluster_router
|
||||
from .key_country_mode import router as country_router
|
||||
from .key_create import router as create_router
|
||||
from .key_discount_mode import router as discount_router
|
||||
|
||||
|
||||
router = Router(name="key_mode_router")
|
||||
|
||||
router.include_routers(create_router, cluster_router, country_router, discount_router)
|
||||
router.include_routers(
|
||||
create_router,
|
||||
tariffs_router,
|
||||
cluster_router,
|
||||
country_router,
|
||||
discount_router,
|
||||
)
|
||||
|
||||
@@ -12,17 +12,18 @@ from aiogram.types import (
|
||||
WebAppInfo,
|
||||
)
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy import update
|
||||
|
||||
from bot import bot
|
||||
from config import REMNAWAVE_WEBAPP, SUPPORT_CHAT_URL
|
||||
from core.bootstrap import MODES_CONFIG
|
||||
from core.bootstrap import BUTTONS_CONFIG, MODES_CONFIG
|
||||
from database import (
|
||||
get_key_details,
|
||||
get_tariff_by_id,
|
||||
get_trial,
|
||||
update_balance,
|
||||
update_trial,
|
||||
)
|
||||
from database.models import Key
|
||||
from handlers.buttons import (
|
||||
CONNECT_DEVICE,
|
||||
MAIN_MENU,
|
||||
@@ -32,7 +33,12 @@ from handlers.buttons import (
|
||||
TV_BUTTON,
|
||||
)
|
||||
from handlers.keys.operations import create_key_on_cluster
|
||||
from handlers.texts import key_message_success
|
||||
from handlers.tariffs.tariff_display import (
|
||||
build_key_created_message,
|
||||
get_effective_limits_for_key,
|
||||
resolve_price_to_charge,
|
||||
resolve_vless_enabled,
|
||||
)
|
||||
from handlers.utils import (
|
||||
edit_or_send_message,
|
||||
generate_random_email,
|
||||
@@ -59,7 +65,10 @@ async def key_cluster_mode(
|
||||
state,
|
||||
session,
|
||||
message_or_query: Message | CallbackQuery | None = None,
|
||||
plan: int = None,
|
||||
plan: int | None = None,
|
||||
selected_device_limit: int | None = None,
|
||||
selected_traffic_gb: int | None = None,
|
||||
selected_price_rub: int | None = None,
|
||||
):
|
||||
target_message = None
|
||||
safe_to_edit = False
|
||||
@@ -85,19 +94,26 @@ async def key_cluster_mode(
|
||||
data = await state.get_data() if state else {}
|
||||
is_trial = data.get("is_trial", False)
|
||||
|
||||
device_limit = 0
|
||||
traffic_limit_gb = 0
|
||||
if selected_device_limit is None:
|
||||
selected_device_limit = data.get("config_selected_device_limit") or data.get("selected_device_limit")
|
||||
|
||||
if plan:
|
||||
tariff = await get_tariff_by_id(session, plan)
|
||||
if tariff:
|
||||
if tariff.get("device_limit") is not None:
|
||||
device_limit = int(tariff["device_limit"])
|
||||
if tariff.get("traffic_limit") is not None:
|
||||
traffic_limit_gb = int(tariff["traffic_limit"])
|
||||
if selected_traffic_gb is None:
|
||||
selected_traffic_gb = data.get("config_selected_traffic_gb") or data.get("selected_traffic_limit_gb")
|
||||
|
||||
effective_tariff_id = plan or data.get("tariff_id")
|
||||
|
||||
device_limit, traffic_limit_bytes = await get_effective_limits_for_key(
|
||||
session=session,
|
||||
tariff_id=effective_tariff_id,
|
||||
selected_device_limit=selected_device_limit,
|
||||
selected_traffic_gb=selected_traffic_gb,
|
||||
)
|
||||
|
||||
forced_cluster = await process_cluster_override(
|
||||
tg_id=tg_id, state_data=data, session=session, plan=plan
|
||||
tg_id=tg_id,
|
||||
state_data=data,
|
||||
session=session,
|
||||
plan=plan,
|
||||
)
|
||||
|
||||
if forced_cluster:
|
||||
@@ -119,6 +135,16 @@ async def key_cluster_mode(
|
||||
await bot.send_message(chat_id=tg_id, text=error_message)
|
||||
return
|
||||
|
||||
if device_limit is None:
|
||||
device_limit = 0
|
||||
if traffic_limit_bytes is None:
|
||||
traffic_limit_bytes = 0
|
||||
|
||||
if selected_price_rub is not None:
|
||||
price_to_charge = selected_price_rub
|
||||
else:
|
||||
price_to_charge = await resolve_price_to_charge(session, data)
|
||||
|
||||
await create_key_on_cluster(
|
||||
cluster_id=least_loaded_cluster,
|
||||
tg_id=tg_id,
|
||||
@@ -128,29 +154,36 @@ async def key_cluster_mode(
|
||||
plan=plan,
|
||||
session=session,
|
||||
hwid_limit=device_limit,
|
||||
traffic_limit_bytes=traffic_limit_gb,
|
||||
traffic_limit_bytes=traffic_limit_bytes,
|
||||
is_trial=is_trial,
|
||||
)
|
||||
|
||||
logger.info(f"[Key Creation] Ключ создан на кластере {least_loaded_cluster} для пользователя {tg_id}")
|
||||
|
||||
await session.execute(
|
||||
update(Key)
|
||||
.where(Key.tg_id == tg_id, Key.email == email)
|
||||
.values(
|
||||
selected_device_limit=selected_device_limit,
|
||||
selected_traffic_limit=selected_traffic_gb,
|
||||
selected_price_rub=price_to_charge,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
key_record = await get_key_details(session, email)
|
||||
if not key_record:
|
||||
raise ValueError(f"Ключ не найден после создания: {email}")
|
||||
|
||||
public_link = key_record.get("key")
|
||||
remnawave_link = key_record.get("remnawave_link")
|
||||
final_link = public_link or remnawave_link or ""
|
||||
final_link = key_record.get("link", "")
|
||||
|
||||
if is_trial:
|
||||
trial_status = await get_trial(session, tg_id)
|
||||
if trial_status in [0, -1]:
|
||||
await update_trial(session, tg_id, 1)
|
||||
|
||||
if data.get("tariff_id"):
|
||||
tariff = await get_tariff_by_id(session, data["tariff_id"])
|
||||
if tariff:
|
||||
await update_balance(session, tg_id, -tariff["price_rub"])
|
||||
if price_to_charge:
|
||||
await update_balance(session, tg_id, -int(price_to_charge))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Error] Ошибка при создании ключа для пользователя {tg_id}: {e}")
|
||||
@@ -169,14 +202,14 @@ async def key_cluster_mode(
|
||||
vless_enabled = False
|
||||
try:
|
||||
if plan:
|
||||
ti = await get_tariff_by_id(session, plan)
|
||||
vless_enabled = bool(ti.get("vless")) if ti else False
|
||||
vless_enabled = await resolve_vless_enabled(session, plan)
|
||||
elif key_record.get("tariff_id"):
|
||||
ti = await get_tariff_by_id(session, key_record["tariff_id"])
|
||||
vless_enabled = bool(ti.get("vless")) if ti else False
|
||||
vless_enabled = await resolve_vless_enabled(session, key_record["tariff_id"])
|
||||
except Exception:
|
||||
vless_enabled = False
|
||||
|
||||
tv_button_enabled = bool(BUTTONS_CONFIG.get("ANDROID_TV_BUTTON_ENABLE"))
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
if vless_enabled:
|
||||
builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=f"connect_router|{key_name}"))
|
||||
@@ -197,7 +230,8 @@ async def key_cluster_mode(
|
||||
and final_link.startswith(("http://", "https://"))
|
||||
):
|
||||
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link)))
|
||||
builder.row(InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"))
|
||||
if tv_button_enabled:
|
||||
builder.row(InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"))
|
||||
else:
|
||||
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"))
|
||||
else:
|
||||
@@ -208,32 +242,28 @@ async def key_cluster_mode(
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
if await process_intercept_key_creation_message(
|
||||
chat_id=tg_id, session=session, target_message=message_or_query
|
||||
chat_id=tg_id,
|
||||
session=session,
|
||||
target_message=message_or_query,
|
||||
):
|
||||
return
|
||||
|
||||
hook_commands = await process_key_creation_complete(
|
||||
chat_id=tg_id, admin=False, session=session, email=email, key_name=key_name
|
||||
chat_id=tg_id,
|
||||
admin=False,
|
||||
session=session,
|
||||
email=email,
|
||||
key_name=key_name,
|
||||
)
|
||||
if hook_commands:
|
||||
builder = insert_hook_buttons(builder, hook_commands)
|
||||
|
||||
expiry_time_local = expiry_time.astimezone(moscow_tz)
|
||||
expiry_time_local - datetime.now(moscow_tz)
|
||||
|
||||
tariff_info = None
|
||||
if plan:
|
||||
tariff_info = await get_tariff_by_id(session, plan)
|
||||
|
||||
tariff_duration = tariff_info["name"]
|
||||
subgroup_title = tariff_info.get("subgroup_title", "") if tariff_info else ""
|
||||
|
||||
key_message_text = key_message_success(
|
||||
final_link,
|
||||
tariff_name=tariff_duration,
|
||||
traffic_limit=tariff_info.get("traffic_limit", 0) if tariff_info else 0,
|
||||
device_limit=tariff_info.get("device_limit", 0) if tariff_info else 0,
|
||||
subgroup_title=subgroup_title,
|
||||
key_message_text = await build_key_created_message(
|
||||
session=session,
|
||||
key_record=key_record,
|
||||
final_link=final_link,
|
||||
selected_device_limit=selected_device_limit,
|
||||
selected_traffic_gb=selected_traffic_gb,
|
||||
)
|
||||
|
||||
default_media_path = "img/pic.jpg"
|
||||
|
||||
@@ -19,13 +19,12 @@ from bot import bot
|
||||
from config import (
|
||||
ADMIN_PASSWORD,
|
||||
ADMIN_USERNAME,
|
||||
HAPP_CRYPTOLINK,
|
||||
REMNAWAVE_LOGIN,
|
||||
REMNAWAVE_PASSWORD,
|
||||
REMNAWAVE_WEBAPP,
|
||||
SUPPORT_CHAT_URL,
|
||||
)
|
||||
from core.bootstrap import MODES_CONFIG
|
||||
from core.bootstrap import BUTTONS_CONFIG, MODES_CONFIG
|
||||
from database import (
|
||||
add_user,
|
||||
check_server_name_by_cluster,
|
||||
@@ -37,7 +36,7 @@ from database import (
|
||||
update_balance,
|
||||
update_trial,
|
||||
)
|
||||
from database.models import Key, Server, ServerSpecialgroup, Tariff
|
||||
from database.models import Key, Server, ServerSpecialgroup
|
||||
from handlers.buttons import (
|
||||
BACK,
|
||||
CONNECT_DEVICE,
|
||||
@@ -49,7 +48,11 @@ from handlers.buttons import (
|
||||
)
|
||||
from handlers.keys.operations import create_client_on_server
|
||||
from handlers.keys.operations.aggregated_links import make_aggregated_link
|
||||
from handlers.texts import SELECT_COUNTRY_MSG, key_message_success
|
||||
from handlers.tariffs.tariff_display import (
|
||||
build_key_created_message,
|
||||
get_effective_limits_for_key,
|
||||
)
|
||||
from handlers.texts import SELECT_COUNTRY_MSG
|
||||
from handlers.utils import (
|
||||
ALLOWED_GROUP_CODES,
|
||||
edit_or_send_message,
|
||||
@@ -70,8 +73,8 @@ from panels.remnawave import RemnawaveAPI, get_vless_link_for_remnawave_by_usern
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
GB = 1024 * 1024 * 1024
|
||||
|
||||
|
||||
async def key_country_mode(
|
||||
@@ -80,8 +83,8 @@ async def key_country_mode(
|
||||
state: FSMContext,
|
||||
session: AsyncSession,
|
||||
message_or_query: Message | CallbackQuery | None = None,
|
||||
old_key_name: str = None,
|
||||
plan: int = None,
|
||||
old_key_name: str | None = None,
|
||||
plan: int | None = None,
|
||||
):
|
||||
target_message = None
|
||||
safe_to_edit = False
|
||||
@@ -99,7 +102,10 @@ async def key_country_mode(
|
||||
data = await state.get_data() if state else {}
|
||||
|
||||
forced_cluster = await process_cluster_override(
|
||||
tg_id=tg_id, state_data=data, session=session, plan=plan
|
||||
tg_id=tg_id,
|
||||
state_data=data,
|
||||
session=session,
|
||||
plan=plan,
|
||||
)
|
||||
if forced_cluster:
|
||||
least_loaded_cluster = forced_cluster
|
||||
@@ -115,9 +121,9 @@ async def key_country_mode(
|
||||
return
|
||||
|
||||
subgroup_title = None
|
||||
tariff = None
|
||||
tariff: dict[str, Any] | None = None
|
||||
if plan:
|
||||
tariff = await get_tariff_by_id(session, plan)
|
||||
tariff = await get_tariff_by_id(session, int(plan))
|
||||
if tariff:
|
||||
subgroup_title = tariff.get("subgroup_title")
|
||||
|
||||
@@ -140,7 +146,7 @@ async def key_country_mode(
|
||||
return
|
||||
|
||||
server_ids = [s["id"] for s in servers]
|
||||
groups_map = {}
|
||||
groups_map: dict[int, list[str]] = {}
|
||||
if server_ids:
|
||||
r = await session.execute(
|
||||
select(ServerSpecialgroup.server_id, ServerSpecialgroup.group_code).where(
|
||||
@@ -168,7 +174,7 @@ async def key_country_mode(
|
||||
gc = (tariff.get("group_code") or "").lower()
|
||||
if gc in ALLOWED_GROUP_CODES:
|
||||
special = gc
|
||||
|
||||
|
||||
if special:
|
||||
bound_servers = [s for s in servers if special in (s.get("special_groups") or [])]
|
||||
if bound_servers:
|
||||
@@ -181,7 +187,7 @@ async def key_country_mode(
|
||||
await bot.send_message(chat_id=tg_id, text=text)
|
||||
return
|
||||
|
||||
available_servers = []
|
||||
available_servers: list[str] = []
|
||||
tasks = [asyncio.create_task(check_server_availability(dict(server), session)) for server in servers]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
@@ -252,13 +258,12 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any):
|
||||
cluster_name = cluster_info["cluster_name"]
|
||||
|
||||
key_tariff_id = record.get("tariff_id")
|
||||
tariff_obj = None
|
||||
tariff_dict: dict[str, Any] | None = None
|
||||
subgroup_title = None
|
||||
if key_tariff_id:
|
||||
res = await session.execute(select(Tariff).where(Tariff.id == key_tariff_id))
|
||||
tariff_obj = res.scalar_one_or_none()
|
||||
if tariff_obj:
|
||||
subgroup_title = tariff_obj.subgroup_title
|
||||
tariff_dict = await get_tariff_by_id(session, int(key_tariff_id))
|
||||
if tariff_dict:
|
||||
subgroup_title = tariff_dict.get("subgroup_title")
|
||||
|
||||
q = (
|
||||
select(
|
||||
@@ -278,7 +283,7 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any):
|
||||
return
|
||||
|
||||
server_ids = [s["id"] for s in servers]
|
||||
groups_map = {}
|
||||
groups_map: dict[int, list[str]] = {}
|
||||
if server_ids:
|
||||
r = await session.execute(
|
||||
select(ServerSpecialgroup.server_id, ServerSpecialgroup.group_code).where(
|
||||
@@ -291,7 +296,7 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any):
|
||||
for server in servers:
|
||||
server["special_groups"] = [g for g in groups_map.get(server["id"], []) if g in ALLOWED_GROUP_CODES]
|
||||
|
||||
available_servers = []
|
||||
available_servers: list[str] = []
|
||||
tasks = [
|
||||
asyncio.create_task(
|
||||
check_server_availability(
|
||||
@@ -314,7 +319,12 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any):
|
||||
|
||||
if subgroup_title and available_servers:
|
||||
available_servers_dict = [s for s in servers if s["server_name"] in available_servers]
|
||||
filtered_servers = await filter_cluster_by_subgroup(session, available_servers_dict, subgroup_title.strip(), cluster_name)
|
||||
filtered_servers = await filter_cluster_by_subgroup(
|
||||
session,
|
||||
available_servers_dict,
|
||||
subgroup_title.strip(),
|
||||
cluster_name,
|
||||
)
|
||||
if filtered_servers:
|
||||
available_servers = [s["server_name"] for s in filtered_servers]
|
||||
else:
|
||||
@@ -327,12 +337,12 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any):
|
||||
)
|
||||
return
|
||||
|
||||
if available_servers and tariff_obj:
|
||||
if available_servers and tariff_dict:
|
||||
special = None
|
||||
gc = (tariff_obj.group_code or "").lower() if hasattr(tariff_obj, 'group_code') else None
|
||||
gc = (tariff_dict.get("group_code") or "").lower()
|
||||
if gc and gc in ALLOWED_GROUP_CODES:
|
||||
special = gc
|
||||
|
||||
|
||||
if special:
|
||||
available_servers_dict = [s for s in servers if s["server_name"] in available_servers]
|
||||
bound_servers = [s for s in available_servers_dict if special in (s.get("special_groups") or [])]
|
||||
@@ -417,13 +427,13 @@ async def handle_country_selection(callback_query: CallbackQuery, session: Any,
|
||||
try:
|
||||
expiry_time = datetime.fromtimestamp(ts, tz=moscow_tz)
|
||||
await finalize_key_creation(
|
||||
tg_id,
|
||||
expiry_time,
|
||||
selected_country,
|
||||
state,
|
||||
session,
|
||||
callback_query,
|
||||
old_key_name,
|
||||
tg_id=tg_id,
|
||||
expiry_time=expiry_time,
|
||||
selected_country=selected_country,
|
||||
state=state,
|
||||
session=session,
|
||||
callback_query=callback_query,
|
||||
old_key_name=old_key_name,
|
||||
)
|
||||
finally:
|
||||
fsm_data = await state.get_data()
|
||||
@@ -438,7 +448,7 @@ async def finalize_key_creation(
|
||||
state: FSMContext | None,
|
||||
session: AsyncSession,
|
||||
callback_query: CallbackQuery,
|
||||
old_key_name: str = None,
|
||||
old_key_name: str | None = None,
|
||||
tariff_id: int | None = None,
|
||||
):
|
||||
from_user = callback_query.from_user
|
||||
@@ -456,6 +466,7 @@ async def finalize_key_creation(
|
||||
|
||||
expiry_time = expiry_time.astimezone(moscow_tz)
|
||||
|
||||
old_key_details: dict[str, Any] | None = None
|
||||
if old_key_name:
|
||||
old_key_details = await get_key_details(session, old_key_name)
|
||||
if not old_key_details:
|
||||
@@ -476,24 +487,51 @@ async def finalize_key_creation(
|
||||
email = key_name.lower()
|
||||
expiry_timestamp = int(expiry_time.timestamp() * 1000)
|
||||
|
||||
traffic_limit_bytes = None
|
||||
device_limit = 0
|
||||
data = await state.get_data() if state else {}
|
||||
is_trial = data.get("is_trial", False)
|
||||
|
||||
if data.get("tariff_id") or tariff_id:
|
||||
tariff_id = data.get("tariff_id") or tariff_id
|
||||
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||
tariff = result.scalar_one_or_none()
|
||||
if tariff:
|
||||
if tariff.traffic_limit is not None:
|
||||
traffic_limit_bytes = int(tariff.traffic_limit) * 1024**3
|
||||
if tariff.device_limit is not None:
|
||||
device_limit = int(tariff.device_limit)
|
||||
else:
|
||||
tariff = None
|
||||
selected_traffic_gb = data.get("config_selected_traffic_gb")
|
||||
if selected_traffic_gb is None:
|
||||
selected_traffic_gb = data.get("selected_traffic_limit_gb")
|
||||
|
||||
need_vless_key = bool(getattr(tariff, "vless", False)) if tariff else False
|
||||
selected_device_limit = data.get("config_selected_device_limit")
|
||||
if selected_device_limit is None:
|
||||
selected_device_limit = data.get("selected_device_limit")
|
||||
|
||||
if old_key_details:
|
||||
if selected_traffic_gb is None:
|
||||
stored_traffic = old_key_details.get("selected_traffic_limit")
|
||||
if stored_traffic is not None:
|
||||
selected_traffic_gb = int(stored_traffic)
|
||||
if selected_device_limit is None:
|
||||
stored_devices = old_key_details.get("selected_device_limit")
|
||||
if stored_devices is not None:
|
||||
selected_device_limit = int(stored_devices)
|
||||
|
||||
price_to_charge = data.get("selected_price_rub")
|
||||
|
||||
effective_tariff_id = data.get("tariff_id") or tariff_id
|
||||
tariff: dict[str, Any] | None = None
|
||||
if effective_tariff_id:
|
||||
tariff_id = int(effective_tariff_id)
|
||||
tariff = await get_tariff_by_id(session, tariff_id)
|
||||
|
||||
device_limit, traffic_limit_bytes = await get_effective_limits_for_key(
|
||||
session=session,
|
||||
tariff_id=tariff_id,
|
||||
selected_device_limit=selected_device_limit,
|
||||
selected_traffic_gb=selected_traffic_gb,
|
||||
)
|
||||
|
||||
if selected_traffic_gb is not None:
|
||||
traffic_limit_gb = int(selected_traffic_gb)
|
||||
else:
|
||||
traffic_limit_gb = int(traffic_limit_bytes / GB) if traffic_limit_bytes else 0
|
||||
|
||||
if price_to_charge is None and tariff and not old_key_name:
|
||||
price_to_charge = tariff.get("price_rub")
|
||||
|
||||
need_vless_key = bool(tariff.get("vless")) if tariff else False
|
||||
|
||||
public_link = None
|
||||
remnawave_link = None
|
||||
@@ -512,7 +550,7 @@ async def finalize_key_creation(
|
||||
cluster_name = cluster_info["cluster_name"]
|
||||
is_full_remnawave = await is_full_remnawave_cluster(cluster_name, session)
|
||||
|
||||
if old_key_name:
|
||||
if old_key_name and old_key_details:
|
||||
old_server_id = old_key_details["server_id"]
|
||||
if old_server_id:
|
||||
result = await session.execute(select(Server).where(Server.server_name == old_server_id))
|
||||
@@ -545,7 +583,7 @@ async def finalize_key_creation(
|
||||
raise ValueError(f"❌ Не удалось авторизоваться в Remnawave ({server_info.server_name})")
|
||||
|
||||
expire_at = datetime.utcfromtimestamp(expiry_timestamp / 1000).isoformat() + "Z"
|
||||
user_data = {
|
||||
user_data: dict[str, Any] = {
|
||||
"username": email,
|
||||
"trafficLimitStrategy": "NO_RESET",
|
||||
"expireAt": expire_at,
|
||||
@@ -583,7 +621,8 @@ async def finalize_key_creation(
|
||||
if need_vless_key and not remnawave_link:
|
||||
links = sub.get("links") or []
|
||||
remnawave_link = next(
|
||||
(l for l in links if isinstance(l, str) and l.lower().startswith("vless://")), None
|
||||
(l for l in links if isinstance(l, str) and l.lower().startswith("vless://")),
|
||||
None,
|
||||
)
|
||||
|
||||
if not remnawave_link:
|
||||
@@ -611,9 +650,11 @@ async def finalize_key_creation(
|
||||
session=session,
|
||||
plan=tariff_id,
|
||||
is_trial=is_trial,
|
||||
total_gb_value=traffic_limit_gb,
|
||||
device_limit_value=device_limit,
|
||||
)
|
||||
|
||||
subgroup_code = tariff.subgroup_title if tariff and tariff.subgroup_title else None
|
||||
subgroup_code = tariff.get("subgroup_title") if tariff and tariff.get("subgroup_title") else None
|
||||
cluster_all = [
|
||||
{
|
||||
"server_name": server_info.server_name,
|
||||
@@ -640,7 +681,11 @@ async def finalize_key_creation(
|
||||
public_link = link_to_show
|
||||
|
||||
if old_key_name:
|
||||
update_data = {"server_id": selected_country, "key": None, "remnawave_link": None}
|
||||
update_data: dict[str, Any] = {
|
||||
"server_id": selected_country,
|
||||
"key": None,
|
||||
"remnawave_link": None,
|
||||
}
|
||||
if public_link and public_link.startswith("vless://"):
|
||||
update_data["key"] = public_link
|
||||
elif public_link and public_link.startswith("http"):
|
||||
@@ -659,17 +704,17 @@ async def finalize_key_creation(
|
||||
remnawave_link=remnawave_link,
|
||||
server_id=selected_country,
|
||||
tariff_id=tariff_id,
|
||||
selected_device_limit=int(selected_device_limit) if selected_device_limit is not None else None,
|
||||
selected_traffic_limit=int(selected_traffic_gb) if selected_traffic_gb is not None else None,
|
||||
selected_price_rub=int(price_to_charge) if price_to_charge is not None else None,
|
||||
)
|
||||
session.add(new_key)
|
||||
if is_trial:
|
||||
trial_status = await get_trial(session, tg_id)
|
||||
if trial_status in [0, -1]:
|
||||
await update_trial(session, tg_id, 1)
|
||||
if tariff_id:
|
||||
result = await session.execute(select(Tariff.price_rub).where(Tariff.id == tariff_id))
|
||||
row = result.scalar_one_or_none()
|
||||
if row:
|
||||
await update_balance(session, tg_id, -row)
|
||||
if not is_trial and price_to_charge:
|
||||
await update_balance(session, tg_id, -int(price_to_charge))
|
||||
|
||||
await session.commit()
|
||||
|
||||
@@ -696,13 +741,16 @@ async def finalize_key_creation(
|
||||
session=session,
|
||||
)
|
||||
|
||||
tv_button_enabled = bool(BUTTONS_CONFIG.get("ANDROID_TV_BUTTON_ENABLE"))
|
||||
|
||||
if panel_type == "remnawave" or is_full_remnawave:
|
||||
if is_vless:
|
||||
builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=f"connect_router|{key_name}"))
|
||||
else:
|
||||
if use_webapp and webapp_url:
|
||||
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, web_app=WebAppInfo(url=webapp_url)))
|
||||
builder.row(InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"))
|
||||
if tv_button_enabled:
|
||||
builder.row(InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"))
|
||||
else:
|
||||
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"))
|
||||
else:
|
||||
@@ -713,32 +761,35 @@ async def finalize_key_creation(
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
if await process_intercept_key_creation_message(
|
||||
chat_id=tg_id, session=session, target_message=callback_query
|
||||
chat_id=tg_id,
|
||||
session=session,
|
||||
target_message=callback_query,
|
||||
):
|
||||
return
|
||||
|
||||
hook_commands = await process_key_creation_complete(
|
||||
chat_id=tg_id, admin=False, session=session, email=email, key_name=key_name
|
||||
chat_id=tg_id,
|
||||
admin=False,
|
||||
session=session,
|
||||
email=email,
|
||||
key_name=key_name,
|
||||
)
|
||||
if hook_commands:
|
||||
builder = insert_hook_buttons(builder, hook_commands)
|
||||
|
||||
t = tariff.name if tariff else "—"
|
||||
subgroup_title = tariff.subgroup_title if tariff and tariff.subgroup_title else ""
|
||||
traffic = tariff.traffic_limit if tariff and tariff.traffic_limit else 0
|
||||
devices = tariff.device_limit if tariff and tariff.device_limit else 0
|
||||
|
||||
key_message_text = key_message_success(
|
||||
public_link or remnawave_link or "Ссылка не найдена",
|
||||
tariff_name=t,
|
||||
traffic_limit=traffic,
|
||||
device_limit=devices,
|
||||
subgroup_title=subgroup_title,
|
||||
key_record = await get_key_details(session, key_name)
|
||||
final_link_for_message = final_link or (key_record.get("link") if key_record else None) or "Ссылка не найдена"
|
||||
message_text = await build_key_created_message(
|
||||
session=session,
|
||||
key_record=key_record,
|
||||
final_link=final_link_for_message,
|
||||
selected_device_limit=selected_device_limit,
|
||||
selected_traffic_gb=selected_traffic_gb,
|
||||
)
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=key_message_text,
|
||||
text=message_text,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path="img/pic.jpg",
|
||||
)
|
||||
@@ -777,16 +828,15 @@ async def check_server_availability(server_info: dict, session: AsyncSession) ->
|
||||
logger.info(f"[Ping] Remnawave сервер {server_name} доступен.")
|
||||
return True
|
||||
|
||||
else:
|
||||
xui = AsyncApi(
|
||||
server_info["api_url"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
logger=logger,
|
||||
)
|
||||
await asyncio.wait_for(xui.login(), timeout=5.0)
|
||||
logger.info(f"[Ping] 3x-ui сервер {server_name} доступен.")
|
||||
return True
|
||||
xui = AsyncApi(
|
||||
server_info["api_url"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
logger=logger,
|
||||
)
|
||||
await asyncio.wait_for(xui.login(), timeout=5.0)
|
||||
logger.info(f"[Ping] 3x-ui сервер {server_name} доступен.")
|
||||
return True
|
||||
|
||||
except TimeoutError:
|
||||
logger.warning(f"[Ping] Сервер {server_name} не ответил вовремя.")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy import select
|
||||
@@ -12,12 +13,11 @@ from database import get_keys, get_tariffs
|
||||
from database.models import Notification
|
||||
from handlers.buttons import MAIN_MENU, RENEW_KEY_NOTIFICATION
|
||||
from handlers.notifications.notify_kb import build_tariffs_keyboard
|
||||
from handlers.tariffs.buy.key_tariffs import select_tariff_plan
|
||||
from handlers.texts import DISCOUNT_TARIFF, DISCOUNT_TARIFF_MAX
|
||||
from handlers.utils import format_discount_time_left
|
||||
from logger import logger
|
||||
|
||||
from .key_create import select_tariff_plan
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
@@ -54,10 +54,10 @@ async def handle_discount_entry(callback: CallbackQuery, session: AsyncSession):
|
||||
|
||||
expires_at = last_time + timedelta(hours=discount_active_hours)
|
||||
await callback.message.edit_text(
|
||||
f"🎯 <b>ЭКСКЛЮЗИВНОЕ ПРЕДЛОЖЕНИЕ!</b>\n\n<blockquote>"
|
||||
f"💎 <b>Специальные тарифы</b> — доступные только для вас!\n"
|
||||
f"🚀 <b>Получите максимум возможностей</b> по выгодной цене!\n"
|
||||
f"</blockquote>\n"
|
||||
"🎯 <b>ЭКСКЛЮЗИВНОЕ ПРЕДЛОЖЕНИЕ!</b>\n\n<blockquote>"
|
||||
"💎 <b>Специальные тарифы</b> — доступные только для вас!\n"
|
||||
"🚀 <b>Получите максимум возможностей</b> по выгодной цене!\n"
|
||||
"</blockquote>\n"
|
||||
f"⏰ <b>Предложение действует всего: {format_discount_time_left(expires_at, discount_active_hours)} — не упустите свой шанс!</b>",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
@@ -74,7 +74,7 @@ async def handle_discount_entry(callback: CallbackQuery, session: AsyncSession):
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("discount_tariff|"))
|
||||
async def handle_discount_tariff_selection(callback: CallbackQuery, session, state):
|
||||
async def handle_discount_tariff_selection(callback: CallbackQuery, session: AsyncSession, state: FSMContext):
|
||||
try:
|
||||
tariff_id = int(callback.data.split("|")[1])
|
||||
fake_callback = CallbackQuery.model_construct(
|
||||
@@ -120,16 +120,17 @@ async def handle_ultra_discount(callback: CallbackQuery, session: AsyncSession):
|
||||
builder.row(InlineKeyboardButton(text=RENEW_KEY_NOTIFICATION, callback_data=f"renew_key|{keys[0].email}"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
expires_at = last_time + timedelta(hours=discount_active_hours)
|
||||
await callback.message.edit_text(
|
||||
f"🎯 <b>УНИКАЛЬНОЕ ФИНАЛЬНОЕ ПРЕДЛОЖЕНИЕ!</b>\n\n<blockquote>"
|
||||
f"💎 <b>Доступ к тарифам с МАКСИМАЛЬНОЙ выгодой</b> — только для вас!\n"
|
||||
f"🚀 <b>Уникальные условия</b> — получите максимум преимуществ по минимальной цене!\n"
|
||||
f"</blockquote>\n"
|
||||
f"⏰ <b>Время ограничено: {format_discount_time_left(last_time, discount_active_hours)} — не упустите шанс!</b>",
|
||||
"🎯 <b>УНИКАЛЬНОЕ ФИНАЛЬНОЕ ПРЕДЛОЖЕНИЕ!</b>\n\n<blockquote>"
|
||||
"💎 <b>Доступ к тарифам с МАКСИМАЛЬНОЙ выгодой</b> — только для вас!\n"
|
||||
"🚀 <b>Уникальные условия</b> — получите максимум преимуществ по минимальной цене!\n"
|
||||
"</blockquote>\n"
|
||||
f"⏰ <b>Время ограничено: {format_discount_time_left(expires_at, discount_active_hours)} — не упустите шанс!</b>",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
else:
|
||||
tariffs = await get_tariffs(session, group_code="discounts_max")
|
||||
tariffs = await get_tariffs(session=session, group_code="discounts_max")
|
||||
if not tariffs:
|
||||
await callback.message.edit_text("❌ Скидочные тарифы временно недоступны.")
|
||||
return
|
||||
|
||||
+235
-18
@@ -15,12 +15,15 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from bot import bot
|
||||
from config import DISCOUNT_ACTIVE_HOURS, RENEW_BUTTON_BEFORE_DAYS, USE_NEW_PAYMENT_FLOW
|
||||
from core.bootstrap import NOTIFICATIONS_CONFIG
|
||||
from core.settings.tariffs_config import normalize_tariff_config
|
||||
from database import (
|
||||
check_tariff_exists,
|
||||
get_balance,
|
||||
get_key_by_server,
|
||||
get_key_details,
|
||||
get_tariff_by_id,
|
||||
reset_key_current_limits_to_selected,
|
||||
save_key_config_with_mode,
|
||||
update_balance,
|
||||
update_key_expiry,
|
||||
)
|
||||
@@ -31,6 +34,7 @@ from handlers.buttons import BACK, MAIN_MENU, MY_SUB, PAYMENT
|
||||
from handlers.keys.operations import renew_key_in_cluster
|
||||
from handlers.payments.currency_rates import format_for_user
|
||||
from handlers.payments.fast_payment_flow import try_fast_payment_flow
|
||||
from handlers.tariffs.tariff_display import GB, get_effective_limits_for_key
|
||||
from handlers.texts import (
|
||||
DISCOUNT_OFFER_MESSAGE,
|
||||
DISCOUNT_OFFER_STEP2,
|
||||
@@ -58,6 +62,7 @@ moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
|
||||
@router.callback_query(F.data.startswith("renew_key|"))
|
||||
async def process_callback_renew_key(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
"""Обрабатывает нажатие кнопки продления конкретного ключа."""
|
||||
tg_id = callback_query.message.chat.id
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
|
||||
@@ -125,9 +130,7 @@ async def process_callback_renew_key(callback_query: CallbackQuery, state: FSMCo
|
||||
current_tariff = await get_tariff_by_id(session, tariff_id)
|
||||
|
||||
forbidden_groups = ["discounts", "discounts_max", "gifts", "trial"]
|
||||
additional_groups = await process_renewal_forbidden_groups(
|
||||
chat_id=tg_id, admin=False, session=session
|
||||
)
|
||||
additional_groups = await process_renewal_forbidden_groups(chat_id=tg_id, admin=False, session=session)
|
||||
forbidden_groups.extend(additional_groups)
|
||||
|
||||
if current_tariff["group_code"] not in forbidden_groups:
|
||||
@@ -164,9 +167,13 @@ async def process_callback_renew_key(callback_query: CallbackQuery, state: FSMCo
|
||||
|
||||
for t in grouped_tariffs.get(None, []):
|
||||
price_text = await format_for_user(session, tg_id, t["price_rub"], language_code)
|
||||
if t.get("configurable"):
|
||||
button_text = f"{t['name']} — от {price_text}"
|
||||
else:
|
||||
button_text = f"{t['name']} — {price_text}"
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"{t['name']} — {price_text}",
|
||||
text=button_text,
|
||||
callback_data=f"renew_plan|{t['id']}",
|
||||
)
|
||||
)
|
||||
@@ -231,6 +238,7 @@ async def process_callback_renew_key(callback_query: CallbackQuery, state: FSMCo
|
||||
|
||||
@router.callback_query(F.data.startswith("renew_subgroup|"))
|
||||
async def show_tariffs_in_renew_subgroup(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
"""Показывает тарифы внутри выбранной подгруппы при продлении."""
|
||||
try:
|
||||
subgroup_hash = callback.data.split("|")[1]
|
||||
|
||||
@@ -317,9 +325,13 @@ async def show_tariffs_in_renew_subgroup(callback: CallbackQuery, state: FSMCont
|
||||
builder = InlineKeyboardBuilder()
|
||||
for t in filtered:
|
||||
price_txt = await format_for_user(session, tg_id, t.get("price_rub", 0), language_code)
|
||||
if t.get("configurable"):
|
||||
button_text = f"{t['name']} — от {price_txt}"
|
||||
else:
|
||||
button_text = f"{t['name']} — {price_txt}"
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"{t['name']} — {price_txt}",
|
||||
text=button_text,
|
||||
callback_data=f"renew_plan|{t['id']}",
|
||||
)
|
||||
)
|
||||
@@ -330,9 +342,7 @@ async def show_tariffs_in_renew_subgroup(callback: CallbackQuery, state: FSMCont
|
||||
hook_builder = InlineKeyboardBuilder()
|
||||
hook_builder.attach(builder)
|
||||
|
||||
hook_commands = await process_renew_tariffs(
|
||||
chat_id=callback.from_user.id, admin=False, session=session
|
||||
)
|
||||
hook_commands = await process_renew_tariffs(chat_id=callback.from_user.id, admin=False, session=session)
|
||||
if hook_commands:
|
||||
hook_builder = insert_hook_buttons(hook_builder, hook_commands)
|
||||
|
||||
@@ -362,6 +372,7 @@ async def show_tariffs_in_renew_subgroup(callback: CallbackQuery, state: FSMCont
|
||||
|
||||
@router.callback_query(F.data.startswith("renew_plan|"))
|
||||
async def process_callback_renew_plan(callback_query: CallbackQuery, state: FSMContext, session: Any):
|
||||
"""Обрабатывает выбор конкретного тарифа для продления."""
|
||||
tg_id = callback_query.from_user.id
|
||||
tariff_id = int(callback_query.data.split("|")[1])
|
||||
|
||||
@@ -391,7 +402,6 @@ async def process_callback_renew_plan(callback_query: CallbackQuery, state: FSMC
|
||||
return
|
||||
|
||||
duration_days = tariff["duration_days"]
|
||||
cost = tariff["price_rub"]
|
||||
total_gb = tariff["traffic_limit"] or 0
|
||||
|
||||
record = await get_key_by_server(session, tg_id, client_id)
|
||||
@@ -409,8 +419,47 @@ async def process_callback_renew_plan(callback_query: CallbackQuery, state: FSMC
|
||||
else:
|
||||
new_expiry_time = int(expiry_time + timedelta(days=duration_days).total_seconds() * 1000)
|
||||
|
||||
if tariff.get("configurable"):
|
||||
selected_devices_db = record.get("selected_device_limit")
|
||||
selected_traffic_db = record.get("selected_traffic_limit")
|
||||
|
||||
if selected_devices_db is not None:
|
||||
config_selected_devices = int(selected_devices_db)
|
||||
else:
|
||||
base_devices = tariff.get("device_limit")
|
||||
config_selected_devices = int(base_devices) if base_devices is not None else None
|
||||
|
||||
if selected_traffic_db is not None:
|
||||
config_selected_traffic_gb = int(selected_traffic_db)
|
||||
else:
|
||||
base_traffic_gb = tariff.get("traffic_limit")
|
||||
config_selected_traffic_gb = int(base_traffic_gb) if base_traffic_gb is not None else None
|
||||
|
||||
await state.update_data(
|
||||
renew_mode="renew",
|
||||
renew_key_name=email,
|
||||
renew_client_id=client_id,
|
||||
renew_tariff_id=tariff_id,
|
||||
renew_new_expiry_time=new_expiry_time,
|
||||
config_selected_device_limit=config_selected_devices,
|
||||
config_selected_traffic_gb=config_selected_traffic_gb,
|
||||
)
|
||||
|
||||
from handlers.tariffs.buy.key_tariffs import start_tariff_config
|
||||
|
||||
await start_tariff_config(
|
||||
callback_query=callback_query,
|
||||
state=state,
|
||||
session=session,
|
||||
tariff_id=tariff_id,
|
||||
)
|
||||
return
|
||||
|
||||
balance = round(await get_balance(session, tg_id), 2)
|
||||
cost = round(cost, 2)
|
||||
|
||||
stored_price = record.get("selected_price_rub")
|
||||
base_cost = tariff["price_rub"]
|
||||
cost = round(stored_price if stored_price is not None else base_cost, 2)
|
||||
|
||||
if balance < cost:
|
||||
required_amount = ceil(cost - balance)
|
||||
@@ -466,7 +515,105 @@ async def process_callback_renew_plan(callback_query: CallbackQuery, state: FSMC
|
||||
logger.error(f"[RENEW] Ошибка при продлении ключа для пользователя {tg_id}: {e}")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("cfg_renew_confirm|"))
|
||||
async def handle_renew_config_confirm(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
"""Подтверждает выбор параметров тарифа при продлении."""
|
||||
tg_id = callback_query.from_user.id
|
||||
|
||||
try:
|
||||
data = await state.get_data()
|
||||
|
||||
client_id = data.get("renew_client_id")
|
||||
email = data.get("renew_key_name")
|
||||
new_expiry_time = data.get("renew_new_expiry_time")
|
||||
tariff_id = data.get("renew_tariff_id")
|
||||
selected_devices = data.get("config_selected_device_limit")
|
||||
selected_traffic_gb = data.get("config_selected_traffic_gb")
|
||||
|
||||
if not client_id or not email or not new_expiry_time or not tariff_id:
|
||||
await callback_query.message.answer("❌ Данные для продления не найдены.")
|
||||
return
|
||||
|
||||
from handlers.tariffs.buy.key_tariffs import calculate_config_price
|
||||
|
||||
tariff = await get_tariff_by_id(session, int(tariff_id))
|
||||
if not tariff or not tariff.get("configurable"):
|
||||
await callback_query.message.answer("❌ Тариф не найден или не поддерживает настройку.")
|
||||
return
|
||||
|
||||
final_price = calculate_config_price(
|
||||
tariff=tariff,
|
||||
selected_device_limit=int(selected_devices) if selected_devices is not None else None,
|
||||
selected_traffic_gb=int(selected_traffic_gb) if selected_traffic_gb is not None else None,
|
||||
)
|
||||
|
||||
balance = round(await get_balance(session, tg_id), 2)
|
||||
cost = round(final_price, 2)
|
||||
|
||||
if balance < cost:
|
||||
required_amount = ceil(cost - balance)
|
||||
|
||||
if USE_NEW_PAYMENT_FLOW:
|
||||
handled = await try_fast_payment_flow(
|
||||
callback_query,
|
||||
session,
|
||||
state,
|
||||
tg_id=tg_id,
|
||||
temp_key="waiting_for_renewal_payment",
|
||||
temp_payload={
|
||||
"tariff_id": int(tariff_id),
|
||||
"client_id": client_id,
|
||||
"cost": cost,
|
||||
"required_amount": required_amount,
|
||||
"new_expiry_time": int(new_expiry_time),
|
||||
"total_gb": int(selected_traffic_gb or 0),
|
||||
"email": email,
|
||||
"selected_device_limit": selected_devices,
|
||||
"selected_traffic_limit": selected_traffic_gb,
|
||||
"selected_price_rub": int(final_price),
|
||||
},
|
||||
required_amount=required_amount,
|
||||
)
|
||||
if handled:
|
||||
return
|
||||
|
||||
language_code = getattr(callback_query.from_user, "language_code", None)
|
||||
required_amount_text = await format_for_user(session, tg_id, float(required_amount), language_code)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=INSUFFICIENT_FUNDS_RENEWAL_MSG.format(required_amount=required_amount_text),
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(f"[RENEW_CONFIG] Продление ключа {client_id} с конфигурацией для пользователя {tg_id}")
|
||||
|
||||
await complete_key_renewal(
|
||||
session=session,
|
||||
tg_id=tg_id,
|
||||
client_id=client_id,
|
||||
email=email,
|
||||
new_expiry_time=int(new_expiry_time),
|
||||
total_gb=int(selected_traffic_gb or 0),
|
||||
cost=cost,
|
||||
callback_query=callback_query,
|
||||
tariff_id=int(tariff_id),
|
||||
selected_device_limit=int(selected_devices) if selected_devices is not None else None,
|
||||
selected_traffic_limit=int(selected_traffic_gb) if selected_traffic_gb is not None else None,
|
||||
selected_price_rub=int(final_price),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[RENEW_CONFIG] Ошибка при продлении по конфигурации для пользователя {tg_id}: {e}")
|
||||
await callback_query.message.answer("❌ Произошла ошибка при продлении. Попробуйте позже.")
|
||||
|
||||
|
||||
async def resolve_cluster_name(session: AsyncSession, server_or_cluster: str) -> str | None:
|
||||
"""Определяет имя кластера по server_id или cluster_name."""
|
||||
result = await session.execute(select(Server).where(Server.cluster_name == server_or_cluster).limit(1))
|
||||
server = result.scalars().first()
|
||||
if server:
|
||||
@@ -487,7 +634,11 @@ async def complete_key_renewal(
|
||||
cost: float,
|
||||
callback_query: CallbackQuery | None,
|
||||
tariff_id: int,
|
||||
selected_device_limit: int | None = None,
|
||||
selected_traffic_limit: int | None = None,
|
||||
selected_price_rub: int | None = None,
|
||||
):
|
||||
"""Продлевает подписку, обновляет лимиты и данные в БД."""
|
||||
try:
|
||||
logger.info(f"[Info] Продление ключа {client_id} по тарифу ID={tariff_id} (Start)")
|
||||
|
||||
@@ -511,6 +662,63 @@ async def complete_key_renewal(
|
||||
logger.error(f"[Error] Тариф с id={tariff_id} не найден.")
|
||||
return
|
||||
|
||||
key_info = await get_key_details(session, email)
|
||||
if not key_info:
|
||||
logger.error(f"[Error] Ключ с client_id={client_id} не найден в БД.")
|
||||
return
|
||||
|
||||
selected_device_limit_db = key_info.get("selected_device_limit")
|
||||
selected_traffic_limit_db = key_info.get("selected_traffic_limit")
|
||||
|
||||
final_device_limit = (
|
||||
int(selected_device_limit) if selected_device_limit is not None else selected_device_limit_db
|
||||
)
|
||||
final_traffic_limit = (
|
||||
int(selected_traffic_limit) if selected_traffic_limit is not None else selected_traffic_limit_db
|
||||
)
|
||||
|
||||
selected_traffic_gb_effective = int(final_traffic_limit) if final_traffic_limit is not None else None
|
||||
selected_device_limit_effective = int(final_device_limit) if final_device_limit is not None else None
|
||||
|
||||
device_limit_effective, traffic_limit_bytes_effective = await get_effective_limits_for_key(
|
||||
session=session,
|
||||
tariff_id=int(tariff_id),
|
||||
selected_device_limit=selected_device_limit_effective,
|
||||
selected_traffic_gb=selected_traffic_gb_effective,
|
||||
)
|
||||
traffic_limit_gb_effective = int(traffic_limit_bytes_effective / GB) if traffic_limit_bytes_effective else 0
|
||||
total_gb = traffic_limit_gb_effective
|
||||
|
||||
cfg = normalize_tariff_config(tariff)
|
||||
raw_device_options = cfg.get("device_options") or tariff.get("device_options") or []
|
||||
raw_traffic_options = cfg.get("traffic_options_gb") or tariff.get("traffic_options_gb") or []
|
||||
|
||||
device_int_options: list[int] = []
|
||||
for value in raw_device_options:
|
||||
try:
|
||||
device_int_options.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
traffic_int_options: list[int] = []
|
||||
for value in raw_traffic_options:
|
||||
try:
|
||||
traffic_int_options.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
has_device_choice = len(device_int_options) > 1
|
||||
has_traffic_choice = len(traffic_int_options) > 1
|
||||
|
||||
if selected_price_rub is None:
|
||||
stored_price = key_info.get("selected_price_rub")
|
||||
if stored_price is not None:
|
||||
final_price_rub = int(stored_price)
|
||||
else:
|
||||
final_price_rub = int(cost)
|
||||
else:
|
||||
final_price_rub = int(selected_price_rub)
|
||||
|
||||
formatted_expiry_date = datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz).strftime("%d %B %Y, %H:%M")
|
||||
formatted_expiry_date = formatted_expiry_date.replace(
|
||||
datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz).strftime("%B"),
|
||||
@@ -519,17 +727,12 @@ async def complete_key_renewal(
|
||||
|
||||
response_message = get_renewal_message(
|
||||
tariff_name=tariff["name"],
|
||||
traffic_limit=tariff.get("traffic_limit") if tariff.get("traffic_limit") is not None else 0,
|
||||
device_limit=tariff.get("device_limit") if tariff.get("device_limit") is not None else 0,
|
||||
traffic_limit=traffic_limit_gb_effective,
|
||||
device_limit=device_limit_effective,
|
||||
expiry_date=formatted_expiry_date,
|
||||
subgroup_title=tariff.get("subgroup_title", ""),
|
||||
)
|
||||
|
||||
key_info = await get_key_details(session, email)
|
||||
if not key_info:
|
||||
logger.error(f"[Error] Ключ с client_id={client_id} не найден в БД.")
|
||||
return
|
||||
|
||||
current_subgroup = None
|
||||
try:
|
||||
current_tariff_id = key_info.get("tariff_id")
|
||||
@@ -556,7 +759,7 @@ async def complete_key_renewal(
|
||||
new_expiry_time=new_expiry_time,
|
||||
total_gb=total_gb,
|
||||
session=session,
|
||||
hwid_device_limit=tariff.get("device_limit") if tariff.get("device_limit") is not None else 0,
|
||||
hwid_device_limit=device_limit_effective,
|
||||
reset_traffic=True,
|
||||
target_subgroup=target_subgroup,
|
||||
old_subgroup=old_subgroup,
|
||||
@@ -570,6 +773,20 @@ async def complete_key_renewal(
|
||||
await session.execute(update(Key).where(Key.email == email).values(tariff_id=tariff_id))
|
||||
await update_balance(session, tg_id, -cost)
|
||||
|
||||
if tariff.get("configurable"):
|
||||
await save_key_config_with_mode(
|
||||
session=session,
|
||||
email=email,
|
||||
selected_devices=final_device_limit,
|
||||
selected_traffic_gb=final_traffic_limit,
|
||||
total_price=int(final_price_rub),
|
||||
has_device_choice=has_device_choice,
|
||||
has_traffic_choice=has_traffic_choice,
|
||||
config_mode="renewal",
|
||||
)
|
||||
if has_device_choice or has_traffic_choice:
|
||||
await reset_key_current_limits_to_selected(session, effective_client_id)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=MY_SUB, callback_data=f"view_key|{email}"))
|
||||
hook_commands = await process_renewal_complete(
|
||||
|
||||
+93
-51
@@ -4,7 +4,6 @@ import os
|
||||
import re
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
|
||||
@@ -13,7 +12,7 @@ from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message, WebAppInfo
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import (
|
||||
@@ -28,9 +27,12 @@ from config import (
|
||||
USE_COUNTRY_SELECTION,
|
||||
)
|
||||
from core.bootstrap import BUTTONS_CONFIG, MODES_CONFIG
|
||||
from database import get_key_details, get_keys, get_servers, get_tariff_by_id
|
||||
from database import get_key_details, get_keys, get_servers
|
||||
from database.models import Key
|
||||
from handlers.buttons import (
|
||||
ADDONS_BUTTON_DEVICES,
|
||||
ADDONS_BUTTON_DEVICES_TRAFFIC,
|
||||
ADDONS_BUTTON_TRAFFIC,
|
||||
ALIAS,
|
||||
BACK,
|
||||
CHANGE_LOCATION,
|
||||
@@ -45,6 +47,7 @@ from handlers.buttons import (
|
||||
TV_BUTTON,
|
||||
UNFREEZE,
|
||||
)
|
||||
from handlers.tariffs.tariff_display import GB, get_key_tariff_addons_state
|
||||
from handlers.texts import (
|
||||
DAYS_LEFT_MESSAGE,
|
||||
FROZEN_SUBSCRIPTION_MSG,
|
||||
@@ -82,7 +85,10 @@ class RenameKeyState(StatesGroup):
|
||||
|
||||
@router.callback_query(F.data == "view_keys")
|
||||
@router.message(F.text == "/subs")
|
||||
async def process_callback_or_message_view_keys(callback_query_or_message: Message | CallbackQuery, session: Any):
|
||||
async def process_callback_or_message_view_keys(
|
||||
callback_query_or_message: Message | CallbackQuery,
|
||||
session: AsyncSession,
|
||||
):
|
||||
if isinstance(callback_query_or_message, CallbackQuery):
|
||||
target_message = callback_query_or_message.message
|
||||
else:
|
||||
@@ -113,7 +119,7 @@ async def process_callback_or_message_view_keys(callback_query_or_message: Messa
|
||||
await target_message.answer(text=error_message)
|
||||
|
||||
|
||||
async def build_keys_response(records, session):
|
||||
async def build_keys_response(records: list[Key] | None, session: AsyncSession):
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
if records:
|
||||
@@ -133,13 +139,13 @@ async def build_keys_response(records, session):
|
||||
formatted_date_full = "без срока действия"
|
||||
|
||||
is_vless = False
|
||||
if hasattr(record, "tariff_id") and record.tariff_id:
|
||||
if getattr(record, "tariff_id", None):
|
||||
try:
|
||||
tariff = await get_tariff_by_id(session, record.tariff_id)
|
||||
if tariff and tariff.get("vless"):
|
||||
is_vless = True
|
||||
from handlers.tariffs.tariff_display import resolve_vless_enabled
|
||||
|
||||
is_vless = await resolve_vless_enabled(session, int(record.tariff_id))
|
||||
except Exception:
|
||||
pass
|
||||
is_vless = False
|
||||
|
||||
icon = "📶" if is_vless else "🔑"
|
||||
|
||||
@@ -213,23 +219,25 @@ async def handle_new_alias_input(message: Message, state: FSMContext, session: A
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("view_key|"))
|
||||
async def process_callback_view_key(callback_query: CallbackQuery, session: Any):
|
||||
async def process_callback_view_key(callback_query: CallbackQuery, session: AsyncSession):
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
image_path = os.path.join("img", "pic_view.jpg")
|
||||
await render_key_info(callback_query.message, session, key_name, image_path)
|
||||
|
||||
|
||||
async def render_key_info(message: Message, session: Any, key_name: str, image_path: str):
|
||||
async def build_key_view_payload(session: AsyncSession, key_name: str):
|
||||
record = await get_key_details(session, key_name)
|
||||
if not record:
|
||||
await message.answer("<b>Информация о подписке не найдена.</b>")
|
||||
return
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
return "<b>Информация о подписке не найдена.</b>", builder.as_markup(), False
|
||||
|
||||
db_key_result = await session.execute(select(Key).where(Key.email == key_name))
|
||||
db_key: Key | None = db_key_result.scalar_one_or_none()
|
||||
|
||||
is_frozen = record["is_frozen"]
|
||||
client_id = record.get("client_id")
|
||||
remnawave_link = record.get("remnawave_link")
|
||||
key = record.get("key")
|
||||
final_link = key or remnawave_link
|
||||
final_link = record.get("link")
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
@@ -237,13 +245,7 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p
|
||||
builder.row(InlineKeyboardButton(text=UNFREEZE, callback_data=f"unfreeze_subscription|{key_name}"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
await edit_or_send_message(
|
||||
target_message=message,
|
||||
text=FROZEN_SUBSCRIPTION_MSG,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=image_path,
|
||||
)
|
||||
return
|
||||
return FROZEN_SUBSCRIPTION_MSG, builder.as_markup(), True
|
||||
|
||||
expiry_time = record["expiry_time"]
|
||||
server_name = record["server_id"]
|
||||
@@ -267,12 +269,33 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p
|
||||
)
|
||||
|
||||
is_full_task = asyncio.create_task(is_full_remnawave_cluster(server_name, session))
|
||||
tariff_task = (
|
||||
asyncio.create_task(get_tariff_by_id(session, record["tariff_id"])) if record.get("tariff_id") else None
|
||||
)
|
||||
|
||||
tariff_name = ""
|
||||
subgroup_title = ""
|
||||
traffic_limit_gb = 0
|
||||
device_limit = 0
|
||||
vless_enabled = False
|
||||
is_tariff_configurable = False
|
||||
addons_devices_enabled = False
|
||||
addons_traffic_enabled = False
|
||||
|
||||
if record.get("tariff_id"):
|
||||
(
|
||||
tariff_name,
|
||||
subgroup_title,
|
||||
traffic_limit_gb,
|
||||
device_limit,
|
||||
vless_enabled,
|
||||
is_tariff_configurable,
|
||||
addons_devices_enabled,
|
||||
addons_traffic_enabled,
|
||||
) = await get_key_tariff_addons_state(
|
||||
session=session,
|
||||
key_record=record,
|
||||
db_key=db_key,
|
||||
)
|
||||
|
||||
is_full_remnawave = await is_full_task
|
||||
tariff = await tariff_task if tariff_task else None
|
||||
|
||||
hwid_count = 0
|
||||
remna_used_gb = None
|
||||
@@ -290,24 +313,16 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p
|
||||
user_data = await api.get_user_by_uuid(client_id)
|
||||
if user_data:
|
||||
used_bytes = user_data.get("usedTrafficBytes", 0)
|
||||
remna_used_gb = round(used_bytes / 1073741824, 1)
|
||||
remna_used_gb = round(used_bytes / GB, 1)
|
||||
traffic_limit_bytes_actual = user_data.get("trafficLimitBytes")
|
||||
if traffic_limit_bytes_actual is not None:
|
||||
if traffic_limit_bytes_actual > 0:
|
||||
traffic_limit_gb = int(traffic_limit_bytes_actual / GB)
|
||||
else:
|
||||
traffic_limit_gb = 0
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка при получении данных Remnawave для {client_id}: {error}")
|
||||
|
||||
tariff_name = ""
|
||||
traffic_limit = 0
|
||||
device_limit = 0
|
||||
subgroup_title = ""
|
||||
vless_enabled = False
|
||||
if tariff:
|
||||
tariff_name = tariff["name"]
|
||||
traffic_limit = tariff.get("traffic_limit", 0)
|
||||
device_limit = tariff.get("device_limit", 0)
|
||||
subgroup_title = tariff.get("subgroup_title", "")
|
||||
vless_enabled = bool(tariff.get("vless"))
|
||||
|
||||
tariff_duration = tariff_name
|
||||
|
||||
country_selection_enabled = bool(MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION))
|
||||
remnawave_webapp_enabled = bool(MODES_CONFIG.get("REMNAWAVE_WEBAPP_ENABLED", REMNAWAVE_WEBAPP))
|
||||
happ_cryptolink_enabled = bool(MODES_CONFIG.get("HAPP_CRYPTOLINK_ENABLED", HAPP_CRYPTOLINK))
|
||||
@@ -319,8 +334,8 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p
|
||||
server_name,
|
||||
server_name if country_selection_enabled else None,
|
||||
hwid_count=hwid_count if device_limit is not None else 0,
|
||||
tariff_name=tariff_duration,
|
||||
traffic_limit=traffic_limit,
|
||||
tariff_name=tariff_name,
|
||||
traffic_limit=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
subgroup_title=subgroup_title,
|
||||
is_remnawave=is_full_remnawave,
|
||||
@@ -335,12 +350,15 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p
|
||||
session=session,
|
||||
)
|
||||
|
||||
tv_button_enabled = bool(BUTTONS_CONFIG.get("ANDROID_TV_BUTTON_ENABLE"))
|
||||
|
||||
if is_full_remnawave and final_link and use_webapp and not happ_cryptolink_enabled:
|
||||
if vless_enabled:
|
||||
builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=f"connect_router|{key_name}"))
|
||||
else:
|
||||
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link)))
|
||||
builder.row(InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{key_name}"))
|
||||
if tv_button_enabled:
|
||||
builder.row(InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{key_name}"))
|
||||
else:
|
||||
if vless_enabled:
|
||||
builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=f"connect_router|{key_name}"))
|
||||
@@ -349,6 +367,15 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p
|
||||
|
||||
builder.row(InlineKeyboardButton(text=RENEW_KEY, callback_data=f"renew_key|{key_name}"))
|
||||
|
||||
if is_tariff_configurable and (addons_devices_enabled or addons_traffic_enabled):
|
||||
if addons_devices_enabled and addons_traffic_enabled:
|
||||
addons_text = ADDONS_BUTTON_DEVICES_TRAFFIC
|
||||
elif addons_devices_enabled:
|
||||
addons_text = ADDONS_BUTTON_DEVICES
|
||||
else:
|
||||
addons_text = ADDONS_BUTTON_TRAFFIC
|
||||
builder.row(InlineKeyboardButton(text=addons_text, callback_data=f"key_addons|{key_name}"))
|
||||
|
||||
hwid_reset_enabled = bool(BUTTONS_CONFIG.get("HWID_RESET_BUTTON_ENABLE", HWID_RESET_BUTTON))
|
||||
qrcode_enabled = bool(BUTTONS_CONFIG.get("QRCODE_BUTTON_ENABLE", QRCODE))
|
||||
delete_key_enabled = bool(BUTTONS_CONFIG.get("DELETE_KEY_BUTTON_ENABLE", ENABLE_DELETE_KEY_BUTTON))
|
||||
@@ -370,19 +397,30 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p
|
||||
builder.row(InlineKeyboardButton(text=FREEZE, callback_data=f"freeze_subscription|{key_name}"))
|
||||
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
module_buttons = await process_view_key_menu(key_name=key_name, session=session)
|
||||
builder = insert_hook_buttons(builder, module_buttons)
|
||||
|
||||
return response_message, builder.as_markup(), False
|
||||
|
||||
|
||||
async def build_key_view_message(session: AsyncSession, email: str):
|
||||
text, reply_markup, _ = await build_key_view_payload(session, email)
|
||||
return text, reply_markup
|
||||
|
||||
|
||||
async def render_key_info(message: Message, session: AsyncSession, key_name: str, image_path: str):
|
||||
text, reply_markup, _ = await build_key_view_payload(session, key_name)
|
||||
await edit_or_send_message(
|
||||
target_message=message,
|
||||
text=response_message,
|
||||
reply_markup=builder.as_markup(),
|
||||
text=text,
|
||||
reply_markup=reply_markup,
|
||||
media_path=image_path,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("reset_hwid|"))
|
||||
async def handle_reset_hwid(callback_query: CallbackQuery, session: Any):
|
||||
async def handle_reset_hwid(callback_query: CallbackQuery, session: AsyncSession):
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
|
||||
record_task = asyncio.create_task(get_key_details(session, key_name))
|
||||
@@ -420,7 +458,10 @@ async def handle_reset_hwid(callback_query: CallbackQuery, session: Any):
|
||||
await callback_query.answer(f"✅ Устройства сброшены ({deleted})", show_alert=True)
|
||||
|
||||
if await process_after_hwid_reset(
|
||||
chat_id=callback_query.from_user.id, admin=False, session=session, key_name=key_name
|
||||
chat_id=callback_query.from_user.id,
|
||||
admin=False,
|
||||
session=session,
|
||||
key_name=key_name,
|
||||
):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
@@ -428,7 +469,8 @@ async def handle_reset_hwid(callback_query: CallbackQuery, session: Any):
|
||||
await callback_query.message.edit_text("✅ Устройства сброшены", reply_markup=builder.as_markup())
|
||||
else:
|
||||
await callback_query.message.edit_caption(
|
||||
caption="✅ Устройства сброшены", reply_markup=builder.as_markup()
|
||||
caption="✅ Устройства сброшены",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# handlers/keys/operations/__init__.py
|
||||
|
||||
from .creation import create_client_on_server, create_key_on_cluster
|
||||
from .deletion import delete_key_from_cluster
|
||||
from .renewal import renew_key_in_cluster
|
||||
|
||||
@@ -3,8 +3,8 @@ import asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import HAPP_CRYPTOLINK, LEGACY_LINKS, PUBLIC_LINK, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE
|
||||
from database import filter_cluster_by_subgroup, get_key_details, get_tariff_by_id
|
||||
from core.bootstrap import MODES_CONFIG
|
||||
from database import filter_cluster_by_subgroup, get_key_details, get_tariff_by_id
|
||||
from logger import logger
|
||||
from panels._3xui import get_vless_link_for_client, get_xui_instance
|
||||
from panels.remnawave import RemnawaveAPI
|
||||
|
||||
@@ -5,10 +5,9 @@ from datetime import datetime
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import HAPP_CRYPTOLINK, PUBLIC_LINK, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE
|
||||
from config import PUBLIC_LINK, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE
|
||||
from database import get_servers, get_tariff_by_id, store_key
|
||||
from database.models import User
|
||||
from core.bootstrap import MODES_CONFIG
|
||||
from handlers.utils import ALLOWED_GROUP_CODES, check_server_key_limit
|
||||
from hooks.processors import process_extract_cryptolink_from_result
|
||||
from logger import (
|
||||
@@ -60,16 +59,35 @@ async def create_key_on_cluster(
|
||||
tariff = None
|
||||
subgroup_title = None
|
||||
need_vless_key = False
|
||||
|
||||
traffic_limit_bytes_value = 0
|
||||
device_limit_value = 0
|
||||
|
||||
if plan is not None:
|
||||
tariff = await get_tariff_by_id(session, plan)
|
||||
if not tariff:
|
||||
raise ValueError(f"Тариф с id={plan} не найден.")
|
||||
|
||||
if traffic_limit_bytes is None:
|
||||
traffic_limit_bytes = int(tariff["traffic_limit"]) if tariff["traffic_limit"] else None
|
||||
raw_traffic_limit = tariff.get("traffic_limit")
|
||||
if raw_traffic_limit:
|
||||
traffic_limit_bytes_value = int(raw_traffic_limit) * 1024 * 1024 * 1024
|
||||
else:
|
||||
traffic_limit_bytes_value = 0
|
||||
else:
|
||||
traffic_limit_bytes_value = int(traffic_limit_bytes)
|
||||
|
||||
if hwid_limit is None:
|
||||
hwid_limit = int(tariff["device_limit"]) if tariff.get("device_limit") is not None else 0
|
||||
raw_device_limit = tariff.get("device_limit")
|
||||
device_limit_value = int(raw_device_limit) if raw_device_limit is not None else 0
|
||||
else:
|
||||
device_limit_value = int(hwid_limit)
|
||||
|
||||
subgroup_title = tariff.get("subgroup_title")
|
||||
need_vless_key = bool(tariff.get("vless"))
|
||||
else:
|
||||
traffic_limit_bytes_value = int(traffic_limit_bytes or 0)
|
||||
device_limit_value = int(hwid_limit or 0)
|
||||
|
||||
if subgroup_title:
|
||||
subgroup_servers = [s for s in enabled_servers if subgroup_title in s.get("tariff_subgroups", [])]
|
||||
@@ -77,7 +95,8 @@ async def create_key_on_cluster(
|
||||
enabled_servers = subgroup_servers
|
||||
else:
|
||||
logger.warning(
|
||||
f"[Key Creation] В кластере {cluster_id} не найдено серверов для подгруппы '{subgroup_title}'. Использую весь кластер."
|
||||
f"[Key Creation] В кластере {cluster_id} не найдено серверов для подгруппы '{subgroup_title}'. "
|
||||
f"Использую весь кластер."
|
||||
)
|
||||
|
||||
special = None
|
||||
@@ -87,13 +106,15 @@ async def create_key_on_cluster(
|
||||
gc = (tariff.get("group_code") or "").lower()
|
||||
if gc in ALLOWED_GROUP_CODES:
|
||||
special = gc
|
||||
|
||||
if special:
|
||||
bound_servers = [s for s in enabled_servers if special in (s.get("special_groups") or [])]
|
||||
if bound_servers:
|
||||
enabled_servers = bound_servers
|
||||
else:
|
||||
logger.info(
|
||||
f"[Key Creation] В кластере {cluster_id} нет серверов со спецгруппой '{special}'. Использую весь кластер."
|
||||
f"[Key Creation] В кластере {cluster_id} нет серверов со спецгруппой '{special}'. "
|
||||
f"Использую весь кластер."
|
||||
)
|
||||
|
||||
remnawave_servers = [
|
||||
@@ -129,6 +150,7 @@ async def create_key_on_cluster(
|
||||
short_uuid = None
|
||||
if remnawave_link and "/" in remnawave_link:
|
||||
short_uuid = remnawave_link.rstrip("/").split("/")[-1]
|
||||
|
||||
user_data = {
|
||||
"username": email,
|
||||
"trafficLimitStrategy": "NO_RESET",
|
||||
@@ -137,18 +159,22 @@ async def create_key_on_cluster(
|
||||
"activeInternalSquads": inbound_ids,
|
||||
"uuid": client_id,
|
||||
}
|
||||
if traffic_limit_bytes and traffic_limit_bytes > 0:
|
||||
user_data["trafficLimitBytes"] = traffic_limit_bytes * 1024 * 1024 * 1024
|
||||
|
||||
if traffic_limit_bytes_value and traffic_limit_bytes_value > 0:
|
||||
user_data["trafficLimitBytes"] = traffic_limit_bytes_value
|
||||
|
||||
if short_uuid:
|
||||
user_data["shortUuid"] = short_uuid
|
||||
user_data["hwidDeviceLimit"] = hwid_limit
|
||||
|
||||
user_data["hwidDeviceLimit"] = device_limit_value
|
||||
|
||||
logger.debug(f"{PANEL_REMNA} Данные для создания клиента: {user_data}")
|
||||
result = await remna.create_user(user_data)
|
||||
if result:
|
||||
remnawave_created = True
|
||||
remnawave_client_id = result.get("uuid")
|
||||
remnawave_link_value = result.get("subscriptionUrl")
|
||||
|
||||
|
||||
remnawave_key = None
|
||||
if need_vless_key:
|
||||
try:
|
||||
@@ -167,7 +193,7 @@ async def create_key_on_cluster(
|
||||
)
|
||||
if crypto_link:
|
||||
remnawave_key = crypto_link
|
||||
|
||||
|
||||
logger.info(f"{PANEL_REMNA} Пользователь создан: {result}")
|
||||
else:
|
||||
logger.warning(f"{PANEL_REMNA} Нет inbound_id у серверов")
|
||||
@@ -189,25 +215,27 @@ async def create_key_on_cluster(
|
||||
plan=plan,
|
||||
session=session,
|
||||
is_trial=is_trial,
|
||||
total_traffic_limit_bytes=traffic_limit_bytes_value,
|
||||
device_limit_value=device_limit_value,
|
||||
)
|
||||
else:
|
||||
await asyncio.gather(
|
||||
*[
|
||||
create_client_on_server(
|
||||
server,
|
||||
tg_id,
|
||||
final_client_id,
|
||||
email,
|
||||
expiry_timestamp,
|
||||
semaphore,
|
||||
plan=plan,
|
||||
session=session,
|
||||
is_trial=is_trial,
|
||||
)
|
||||
for server in xui_servers
|
||||
],
|
||||
return_exceptions=True,
|
||||
)
|
||||
tasks = [
|
||||
create_client_on_server(
|
||||
server,
|
||||
tg_id,
|
||||
final_client_id,
|
||||
email,
|
||||
expiry_timestamp,
|
||||
semaphore,
|
||||
plan=plan,
|
||||
session=session,
|
||||
is_trial=is_trial,
|
||||
total_traffic_limit_bytes=traffic_limit_bytes_value,
|
||||
device_limit_value=device_limit_value,
|
||||
)
|
||||
for server in xui_servers
|
||||
]
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
cluster_all = enabled_servers
|
||||
subgroup_code = subgroup_title if subgroup_title else None
|
||||
@@ -254,12 +282,15 @@ async def create_client_on_server(
|
||||
email: str,
|
||||
expiry_timestamp: int,
|
||||
semaphore: asyncio.Semaphore,
|
||||
plan: int = None,
|
||||
session=None,
|
||||
plan: int | None = None,
|
||||
session: AsyncSession | None = None,
|
||||
is_trial: bool = False,
|
||||
total_traffic_limit_bytes: int = 0,
|
||||
device_limit_value: int = 0,
|
||||
):
|
||||
logger.debug(
|
||||
f"{PANEL_XUI} [Client] Вход в create_client_on_server: сервер={server_info.get('server_name')}, план={plan}, is_trial={is_trial}"
|
||||
f"{PANEL_XUI} [Client] Вход в create_client_on_server: "
|
||||
f"сервер={server_info.get('server_name')}, план={plan}, is_trial={is_trial}"
|
||||
)
|
||||
|
||||
async with semaphore:
|
||||
@@ -278,23 +309,27 @@ async def create_client_on_server(
|
||||
unique_email = email
|
||||
sub_id = unique_email
|
||||
|
||||
total_gb_value = 0
|
||||
device_limit_value = 0
|
||||
|
||||
if plan is not None:
|
||||
if plan is not None and (total_traffic_limit_bytes == 0 or device_limit_value == 0):
|
||||
tariff = await get_tariff_by_id(session, plan)
|
||||
logger.debug(f"{PANEL_XUI} [Tariff Debug] Получен тариф: {tariff}")
|
||||
if not tariff:
|
||||
raise ValueError(f"{PANEL_XUI} Тариф с id={plan} не найден.")
|
||||
|
||||
total_gb_value = int(tariff["traffic_limit"]) if tariff["traffic_limit"] else 0
|
||||
device_limit_value = int(tariff["device_limit"]) if tariff.get("device_limit") is not None else 0
|
||||
if total_traffic_limit_bytes == 0:
|
||||
raw_limit = tariff.get("traffic_limit")
|
||||
base_gb = int(raw_limit) if raw_limit else 0
|
||||
total_traffic_limit_bytes = base_gb * 1024 * 1024 * 1024
|
||||
|
||||
if device_limit_value == 0:
|
||||
raw_device_limit = tariff.get("device_limit")
|
||||
device_limit_value = int(raw_device_limit) if raw_device_limit is not None else 0
|
||||
|
||||
try:
|
||||
logger.debug(
|
||||
f"{PANEL_XUI} [Client] Вызов add_client: email={email}, client_id={client_id}, GB={total_gb_value}, Devices={device_limit_value}"
|
||||
f"{PANEL_XUI} [Client] Вызов add_client: email={email}, client_id={client_id}, "
|
||||
f"bytes={total_traffic_limit_bytes}, Devices={device_limit_value}"
|
||||
)
|
||||
traffic_limit_bytes = total_gb_value * 1024 * 1024 * 1024
|
||||
traffic_limit_bytes = total_traffic_limit_bytes
|
||||
await add_client(
|
||||
xui,
|
||||
ClientConfig(
|
||||
|
||||
@@ -14,6 +14,7 @@ from database import (
|
||||
update_key_expiry,
|
||||
update_key_link,
|
||||
)
|
||||
from hooks.processors import process_get_cryptolink_after_renewal
|
||||
from logger import (
|
||||
CLOGGER as logger,
|
||||
PANEL_REMNA,
|
||||
@@ -24,7 +25,6 @@ from panels.remnawave import RemnawaveAPI
|
||||
|
||||
from .aggregated_links import make_aggregated_link
|
||||
from .subgroup_migration import migrate_between_subgroups
|
||||
from hooks.processors import process_get_cryptolink_after_renewal
|
||||
|
||||
|
||||
async def resolve_cluster(session: AsyncSession, cluster_id: str):
|
||||
@@ -293,7 +293,8 @@ async def renew_key_in_cluster(
|
||||
remna_link_override = None
|
||||
if remna_ok and cluster_scope:
|
||||
remnawave_nodes = [
|
||||
s for s in cluster_scope
|
||||
s
|
||||
for s in cluster_scope
|
||||
if str(s.get("panel_type", "3x-ui")).lower() == "remnawave" and s.get("inbound_id")
|
||||
]
|
||||
if remnawave_nodes:
|
||||
@@ -321,7 +322,7 @@ async def renew_key_in_cluster(
|
||||
await update_key_link(session, email, key_link)
|
||||
except Exception as le:
|
||||
logger.warning(f"[Link] ошибка генерации/сохранения после продления: {le}")
|
||||
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@@ -5,8 +5,8 @@ from datetime import datetime
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import HAPP_CRYPTOLINK, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE
|
||||
from database import filter_cluster_by_subgroup, update_key_client_id
|
||||
from core.bootstrap import MODES_CONFIG
|
||||
from database import filter_cluster_by_subgroup, update_key_client_id
|
||||
from logger import (
|
||||
CLOGGER as logger,
|
||||
PANEL_REMNA,
|
||||
@@ -33,6 +33,13 @@ async def ensure_on_remnawave(
|
||||
if not servers:
|
||||
return None, None
|
||||
|
||||
if total_gb is None:
|
||||
total_gb = 0
|
||||
if hwid_device_limit is None:
|
||||
hwid_device_limit = 0
|
||||
else:
|
||||
hwid_device_limit = int(hwid_device_limit)
|
||||
|
||||
inbounds = [s.get("inbound_id") for s in servers if s.get("inbound_id")]
|
||||
|
||||
api = RemnawaveAPI(servers[0]["api_url"])
|
||||
@@ -77,8 +84,8 @@ async def ensure_on_remnawave(
|
||||
}
|
||||
if traffic_bytes > 0:
|
||||
payload["trafficLimitBytes"] = traffic_bytes
|
||||
if hwid_device_limit is not None:
|
||||
payload["hwidDeviceLimit"] = hwid_device_limit
|
||||
|
||||
payload["hwidDeviceLimit"] = hwid_device_limit
|
||||
|
||||
created = await api.create_user(payload)
|
||||
new_uuid = created.get("uuid") if isinstance(created, dict) else None
|
||||
|
||||
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from config import PUBLIC_LINK, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE
|
||||
from database import filter_cluster_by_subgroup, get_servers, get_tariff_by_id, store_key
|
||||
from database.models import Key, Tariff
|
||||
from handlers.tariffs.tariff_display import GB, get_effective_limits_for_key
|
||||
from handlers.utils import get_least_loaded_cluster
|
||||
from logger import (
|
||||
CLOGGER as logger,
|
||||
@@ -61,7 +62,6 @@ async def update_key_on_cluster(
|
||||
xui_servers = [s for s in cluster if s.get("panel_type", "3x-ui").lower() == "3x-ui"]
|
||||
|
||||
remnawave_client_id = None
|
||||
remnawave_key = None
|
||||
remnawave_link_value = None
|
||||
|
||||
if remnawave_servers:
|
||||
@@ -106,8 +106,7 @@ async def update_key_on_cluster(
|
||||
if result:
|
||||
remnawave_client_id = result.get("uuid")
|
||||
remnawave_link_value = result.get("subscriptionUrl")
|
||||
remnawave_key = None
|
||||
|
||||
|
||||
logger.info(f"{PANEL_REMNA} Клиент заново создан, uuid={remnawave_client_id}")
|
||||
else:
|
||||
logger.error(f"{PANEL_REMNA} Ошибка создания клиента")
|
||||
@@ -181,7 +180,7 @@ async def update_subscription(
|
||||
remnawave_link: str = None,
|
||||
) -> None:
|
||||
result = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.email == email))
|
||||
record = result.scalar_one_or_none()
|
||||
record: Key | None = result.scalar_one_or_none()
|
||||
if not record:
|
||||
raise ValueError(f"The key {email} does not exist in database")
|
||||
|
||||
@@ -193,6 +192,10 @@ async def update_subscription(
|
||||
remnawave_link = remnawave_link or record.remnawave_link
|
||||
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
|
||||
|
||||
selected_device_limit = getattr(record, "selected_device_limit", None)
|
||||
selected_traffic_limit = getattr(record, "selected_traffic_limit", None)
|
||||
selected_price_rub = getattr(record, "selected_price_rub", None)
|
||||
|
||||
tariff = None
|
||||
subgroup_code = getattr(record, "subgroup_code", None)
|
||||
|
||||
@@ -243,10 +246,20 @@ async def update_subscription(
|
||||
return
|
||||
cluster_servers = prefiltered
|
||||
|
||||
traffic_limit = None
|
||||
device_limit = None
|
||||
if tariff:
|
||||
traffic_limit = int(tariff.traffic_limit) if tariff.traffic_limit is not None else None
|
||||
traffic_limit_gb = None
|
||||
device_limit = 0
|
||||
|
||||
if tariff and tariff_id:
|
||||
device_limit_effective, traffic_limit_bytes_effective = await get_effective_limits_for_key(
|
||||
session=session,
|
||||
tariff_id=int(tariff_id),
|
||||
selected_device_limit=int(selected_device_limit) if selected_device_limit is not None else None,
|
||||
selected_traffic_gb=int(selected_traffic_limit) if selected_traffic_limit is not None else None,
|
||||
)
|
||||
device_limit = int(device_limit_effective or 0)
|
||||
traffic_limit_gb = int(traffic_limit_bytes_effective / GB) if traffic_limit_bytes_effective else None
|
||||
elif tariff:
|
||||
traffic_limit_gb = int(tariff.traffic_limit) if tariff.traffic_limit is not None else None
|
||||
device_limit = int(tariff.device_limit) if tariff.device_limit is not None else 0
|
||||
|
||||
new_client_id, remnawave_link_value = await update_key_on_cluster(
|
||||
@@ -256,7 +269,7 @@ async def update_subscription(
|
||||
expiry_time=expiry_time,
|
||||
cluster_id=new_cluster_id,
|
||||
session=session,
|
||||
traffic_limit=traffic_limit,
|
||||
traffic_limit=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
remnawave_link=remnawave_link,
|
||||
subgroup_code=subgroup_code,
|
||||
@@ -287,4 +300,7 @@ async def update_subscription(
|
||||
server_id=new_cluster_id,
|
||||
tariff_id=tariff_id,
|
||||
alias=alias,
|
||||
selected_device_limit=selected_device_limit,
|
||||
selected_traffic_limit=selected_traffic_limit,
|
||||
selected_price_rub=selected_price_rub,
|
||||
)
|
||||
|
||||
@@ -26,24 +26,27 @@ from database import (
|
||||
add_notification,
|
||||
check_notification_time,
|
||||
check_notifications_bulk,
|
||||
check_tariff_exists,
|
||||
delete_key,
|
||||
delete_notification,
|
||||
get_all_keys,
|
||||
get_balance,
|
||||
get_last_notification_time,
|
||||
get_tariff_by_id,
|
||||
get_tariffs_for_cluster,
|
||||
update_balance,
|
||||
update_key_expiry,
|
||||
update_key_tariff,
|
||||
)
|
||||
from database.tariffs import (
|
||||
check_tariff_exists,
|
||||
get_tariff_by_id,
|
||||
get_tariffs_for_cluster,
|
||||
)
|
||||
from handlers.keys.operations import delete_key_from_cluster, renew_key_in_cluster
|
||||
from handlers.notifications.notify_kb import (
|
||||
build_change_tariff_kb,
|
||||
build_notification_expired_kb,
|
||||
build_notification_kb,
|
||||
)
|
||||
from handlers.tariffs.tariff_display import GB, get_effective_limits_for_key
|
||||
from handlers.texts import (
|
||||
KEY_CANNOT_RENEW_CURRENT,
|
||||
KEY_DELETED_MSG,
|
||||
@@ -382,6 +385,27 @@ async def handle_expired_keys(
|
||||
tariff = tariffs[0] if tariffs else None
|
||||
|
||||
if tariff and balance >= tariff["price_rub"]:
|
||||
selected_device_limit = getattr(key, "selected_device_limit", None)
|
||||
selected_traffic_limit = getattr(key, "selected_traffic_limit", None)
|
||||
selected_traffic_gb = int(selected_traffic_limit) if selected_traffic_limit is not None else None
|
||||
|
||||
device_limit_effective, traffic_limit_bytes_effective = await get_effective_limits_for_key(
|
||||
session=session,
|
||||
tariff_id=int(tariff["id"]),
|
||||
selected_device_limit=int(selected_device_limit) if selected_device_limit is not None else None,
|
||||
selected_traffic_gb=selected_traffic_gb,
|
||||
)
|
||||
traffic_limit_gb_effective = (
|
||||
int(traffic_limit_bytes_effective / GB) if traffic_limit_bytes_effective else 0
|
||||
)
|
||||
|
||||
standard_caption = get_renewal_message(
|
||||
tariff_name=tariff.get("name", ""),
|
||||
traffic_limit=traffic_limit_gb_effective,
|
||||
device_limit=device_limit_effective,
|
||||
subgroup_title=tariff.get("subgroup_title", ""),
|
||||
)
|
||||
|
||||
await process_auto_renew_or_notify(
|
||||
bot,
|
||||
session,
|
||||
@@ -389,12 +413,7 @@ async def handle_expired_keys(
|
||||
notification_id,
|
||||
1,
|
||||
"notify_expired.jpg",
|
||||
get_renewal_message(
|
||||
tariff_name=tariff.get("name", ""),
|
||||
traffic_limit=tariff.get("traffic_limit") if tariff.get("traffic_limit") is not None else 0,
|
||||
device_limit=tariff.get("device_limit") if tariff.get("device_limit") is not None else 0,
|
||||
subgroup_title=tariff.get("subgroup_title", ""),
|
||||
),
|
||||
standard_caption,
|
||||
)
|
||||
|
||||
except Exception as error:
|
||||
@@ -485,6 +504,7 @@ async def process_auto_renew_or_notify(
|
||||
standard_photo: str,
|
||||
standard_caption: str,
|
||||
):
|
||||
"""Пытается автопродлить ключ или отправить уведомление."""
|
||||
tg_id = key.tg_id
|
||||
email = key.email or ""
|
||||
renew_notification_id = f"{email}_renew"
|
||||
@@ -508,33 +528,39 @@ async def process_auto_renew_or_notify(
|
||||
|
||||
selected_tariff = None
|
||||
|
||||
if not tariff_id:
|
||||
selected_tariff = None
|
||||
else:
|
||||
if await check_tariff_exists(conn, tariff_id):
|
||||
current_tariff = await get_tariff_by_id(conn, tariff_id)
|
||||
if tariff_id and await check_tariff_exists(conn, tariff_id):
|
||||
current_tariff = await get_tariff_by_id(conn, tariff_id)
|
||||
|
||||
forbidden_groups = ["discounts", "discounts_max", "gifts", "trial"]
|
||||
forbidden_groups = ["discounts", "discounts_max", "gifts", "trial"]
|
||||
try:
|
||||
hook_results = await run_hooks("renewal_forbidden_groups", chat_id=tg_id, admin=False, session=conn)
|
||||
for hook_result in hook_results:
|
||||
additional_groups = hook_result.get("additional_groups", [])
|
||||
forbidden_groups.extend(additional_groups)
|
||||
except Exception as error:
|
||||
logger.warning(f"[AUTO_RENEW] Ошибка при получении дополнительных групп: {error}")
|
||||
|
||||
try:
|
||||
hook_results = await run_hooks("renewal_forbidden_groups", chat_id=tg_id, admin=False, session=conn)
|
||||
for hook_result in hook_results:
|
||||
additional_groups = hook_result.get("additional_groups", [])
|
||||
forbidden_groups.extend(additional_groups)
|
||||
except Exception as error:
|
||||
logger.warning(f"[AUTO_RENEW] Ошибка при получении дополнительных групп: {error}")
|
||||
|
||||
if current_tariff["group_code"] in forbidden_groups:
|
||||
selected_tariff = None
|
||||
elif balance >= current_tariff["price_rub"]:
|
||||
if current_tariff and current_tariff["group_code"] not in forbidden_groups:
|
||||
stored_price = getattr(key, "selected_price_rub", None)
|
||||
renewal_cost = float(stored_price) if stored_price is not None else float(current_tariff["price_rub"])
|
||||
if balance >= renewal_cost:
|
||||
selected_tariff = current_tariff
|
||||
else:
|
||||
selected_tariff = None
|
||||
else:
|
||||
selected_tariff = None
|
||||
else:
|
||||
selected_tariff = None
|
||||
|
||||
if not selected_tariff:
|
||||
expiry_data = await prepare_key_expiry_data(key, conn, int(datetime.now(moscow_tz).timestamp() * 1000))
|
||||
expiry_data = await prepare_key_expiry_data(
|
||||
key,
|
||||
conn,
|
||||
int(datetime.now(moscow_tz).timestamp() * 1000),
|
||||
)
|
||||
|
||||
use_change_tariff_kb = False
|
||||
message_text = None
|
||||
|
||||
if tariff_id and await check_tariff_exists(conn, tariff_id):
|
||||
current_tariff = await get_tariff_by_id(conn, tariff_id)
|
||||
@@ -552,7 +578,6 @@ async def process_auto_renew_or_notify(
|
||||
|
||||
if current_tariff["group_code"] in forbidden_groups:
|
||||
use_change_tariff_kb = True
|
||||
|
||||
message_text = KEY_CANNOT_RENEW_CURRENT.format(
|
||||
email=email,
|
||||
hours_left_formatted=expiry_data["hours_left_formatted"],
|
||||
@@ -562,7 +587,6 @@ async def process_auto_renew_or_notify(
|
||||
)
|
||||
else:
|
||||
use_change_tariff_kb = True
|
||||
|
||||
message_text = KEY_CANNOT_RENEW_CURRENT.format(
|
||||
email=email,
|
||||
hours_left_formatted=expiry_data["hours_left_formatted"],
|
||||
@@ -581,17 +605,29 @@ async def process_auto_renew_or_notify(
|
||||
keyboard = build_notification_kb(email)
|
||||
|
||||
await add_notification(conn, tg_id, notification_id)
|
||||
text_to_send = message_text if "message_text" in locals() else standard_caption
|
||||
text_to_send = message_text if message_text is not None else standard_caption
|
||||
await send_notification(bot, tg_id, standard_photo, text_to_send, keyboard)
|
||||
return
|
||||
|
||||
client_id = key.client_id
|
||||
current_expiry = key.expiry_time
|
||||
duration_days = selected_tariff["duration_days"]
|
||||
renewal_cost = selected_tariff["price_rub"]
|
||||
traffic_limit = selected_tariff["traffic_limit"]
|
||||
device_limit = selected_tariff["device_limit"]
|
||||
total_gb = traffic_limit if traffic_limit else 0
|
||||
|
||||
stored_price = getattr(key, "selected_price_rub", None)
|
||||
renewal_cost = float(stored_price) if stored_price is not None else float(selected_tariff["price_rub"])
|
||||
|
||||
selected_device_limit = getattr(key, "selected_device_limit", None)
|
||||
selected_traffic_limit = getattr(key, "selected_traffic_limit", None)
|
||||
selected_traffic_gb = int(selected_traffic_limit) if selected_traffic_limit is not None else None
|
||||
|
||||
device_limit_effective, traffic_limit_bytes_effective = await get_effective_limits_for_key(
|
||||
session=conn,
|
||||
tariff_id=int(selected_tariff["id"]),
|
||||
selected_device_limit=int(selected_device_limit) if selected_device_limit is not None else None,
|
||||
selected_traffic_gb=selected_traffic_gb,
|
||||
)
|
||||
traffic_limit_gb_effective = int(traffic_limit_bytes_effective / GB) if traffic_limit_bytes_effective else 0
|
||||
total_gb = traffic_limit_gb_effective
|
||||
|
||||
new_expiry_time = (
|
||||
current_expiry
|
||||
@@ -600,7 +636,6 @@ async def process_auto_renew_or_notify(
|
||||
) + duration_days * 24 * 60 * 60 * 1000
|
||||
|
||||
formatted_expiry_date = datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz).strftime("%d %B %Y, %H:%M")
|
||||
|
||||
formatted_expiry_date = formatted_expiry_date.replace(
|
||||
datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz).strftime("%B"),
|
||||
get_russian_month(datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz)),
|
||||
@@ -619,7 +654,7 @@ async def process_auto_renew_or_notify(
|
||||
client_id=client_id,
|
||||
new_expiry_time=int(new_expiry_time),
|
||||
total_gb=total_gb,
|
||||
hwid_device_limit=device_limit,
|
||||
hwid_device_limit=device_limit_effective,
|
||||
session=conn,
|
||||
target_subgroup=key_subgroup,
|
||||
old_subgroup=key_subgroup,
|
||||
@@ -633,10 +668,8 @@ async def process_auto_renew_or_notify(
|
||||
|
||||
renewed_message = get_renewal_message(
|
||||
tariff_name=selected_tariff["name"],
|
||||
traffic_limit=selected_tariff.get("traffic_limit")
|
||||
if selected_tariff.get("traffic_limit") is not None
|
||||
else 0,
|
||||
device_limit=selected_tariff.get("device_limit") if selected_tariff.get("device_limit") is not None else 0,
|
||||
traffic_limit=traffic_limit_gb_effective,
|
||||
device_limit=device_limit_effective,
|
||||
expiry_date=formatted_expiry_date,
|
||||
subgroup_title=selected_tariff.get("subgroup_title", ""),
|
||||
)
|
||||
|
||||
@@ -15,11 +15,15 @@ from aiogram.exceptions import (
|
||||
from aiogram.types import BufferedInputFile, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import create_blocked_user, get_tariff_by_id
|
||||
from handlers.utils import format_hours
|
||||
from database import create_blocked_user
|
||||
from handlers.tariffs.tariff_display import get_key_tariff_display
|
||||
from handlers.utils import format_hours, format_minutes, get_russian_month
|
||||
from logger import logger
|
||||
|
||||
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
|
||||
|
||||
async def send_messages_with_limit(
|
||||
bot: Bot,
|
||||
messages: list[dict],
|
||||
@@ -27,10 +31,6 @@ async def send_messages_with_limit(
|
||||
source_file: str = None,
|
||||
messages_per_second: int = 25,
|
||||
):
|
||||
"""
|
||||
Отправляет сообщения с ограничением по количеству сообщений в секунду.
|
||||
Возвращает список результатов отправки (True для успеха, False для ошибки).
|
||||
"""
|
||||
batch_size = messages_per_second
|
||||
results = []
|
||||
|
||||
@@ -65,9 +65,6 @@ async def send_messages_with_limit(
|
||||
|
||||
|
||||
async def try_add_blocked_user(tg_id: int, session: AsyncSession, source_file: str | None):
|
||||
"""
|
||||
Добавляет пользователя в список заблокировавших бота, если требуется.
|
||||
"""
|
||||
if source_file == "special_notifications" and session:
|
||||
try:
|
||||
await create_blocked_user(session, tg_id)
|
||||
@@ -108,9 +105,6 @@ async def send_notification(
|
||||
caption: str,
|
||||
keyboard: InlineKeyboardMarkup | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Отправляет уведомление пользователю.
|
||||
"""
|
||||
if image_filename is None:
|
||||
return await _send_text_notification(bot, tg_id, caption, keyboard)
|
||||
|
||||
@@ -131,7 +125,6 @@ async def _send_photo_notification(
|
||||
caption: str,
|
||||
keyboard: InlineKeyboardMarkup | None = None,
|
||||
) -> bool:
|
||||
"""Отправляет уведомление с изображением."""
|
||||
try:
|
||||
async with aiofiles.open(photo_path, "rb") as image_file:
|
||||
image_data = await image_file.read()
|
||||
@@ -152,7 +145,6 @@ async def _send_text_notification(
|
||||
caption: str,
|
||||
keyboard: InlineKeyboardMarkup | None = None,
|
||||
) -> bool:
|
||||
"""Отправляет текстовое уведомление."""
|
||||
try:
|
||||
await bot.send_message(tg_id, caption, reply_markup=keyboard)
|
||||
return True
|
||||
@@ -164,35 +156,73 @@ async def _send_text_notification(
|
||||
|
||||
|
||||
async def prepare_key_expiry_data(key, session: AsyncSession, current_time: int) -> dict:
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
"""Готовит данные об истечении подписки для уведомлений."""
|
||||
if isinstance(key, dict):
|
||||
expiry_timestamp = key.get("expiry_time")
|
||||
email = key.get("email") or ""
|
||||
record = dict(key)
|
||||
else:
|
||||
expiry_timestamp = getattr(key, "expiry_time", None)
|
||||
email = getattr(key, "email", "") or ""
|
||||
record = {
|
||||
"tariff_id": getattr(key, "tariff_id", None),
|
||||
"server_id": getattr(key, "server_id", None),
|
||||
"client_id": getattr(key, "client_id", None),
|
||||
"selected_device_limit": getattr(key, "selected_device_limit", None),
|
||||
"selected_traffic_limit": getattr(key, "selected_traffic_limit", None),
|
||||
}
|
||||
|
||||
expiry_timestamp = key.expiry_time
|
||||
hours_left = int((expiry_timestamp - current_time) / (1000 * 3600))
|
||||
hours_left_formatted = (
|
||||
f"⏳ Осталось времени: {format_hours(hours_left)}" if hours_left > 0 else "⏳ Последний день подписки!"
|
||||
)
|
||||
if not expiry_timestamp:
|
||||
return {
|
||||
"hours_left_formatted": "",
|
||||
"formatted_expiry_date": "",
|
||||
"tariff_name": "—",
|
||||
"tariff_details": "",
|
||||
}
|
||||
|
||||
delta_ms = max(0, expiry_timestamp - current_time)
|
||||
total_minutes = delta_ms // (60 * 1000)
|
||||
hours_left = total_minutes // 60
|
||||
minutes_left = total_minutes % 60
|
||||
|
||||
if hours_left > 0 or minutes_left > 0:
|
||||
parts = []
|
||||
if hours_left > 0:
|
||||
parts.append(format_hours(hours_left))
|
||||
if minutes_left > 0:
|
||||
parts.append(format_minutes(minutes_left))
|
||||
hours_left_formatted = f"⏳ Осталось времени: {' '.join(parts)}"
|
||||
else:
|
||||
hours_left_formatted = "⏳ Последний день подписки!"
|
||||
|
||||
expiry_datetime = datetime.fromtimestamp(expiry_timestamp / 1000, tz=moscow_tz)
|
||||
formatted_expiry_date = expiry_datetime.strftime("%d %B %Y, %H:%M (МСК)")
|
||||
month_name = get_russian_month(expiry_datetime)
|
||||
formatted_expiry_date = expiry_datetime.strftime(f"%d {month_name} %Y, %H:%M (МСК)")
|
||||
|
||||
tariff_name = "—"
|
||||
tariff_details = ""
|
||||
subgroup_title = ""
|
||||
traffic_limit_gb = 0
|
||||
device_limit = 0
|
||||
|
||||
if getattr(key, "tariff_id", None):
|
||||
tariff = await get_tariff_by_id(session, key.tariff_id)
|
||||
if tariff:
|
||||
tariff_name = tariff.get("name") or "—"
|
||||
traffic_limit = tariff.get("traffic_limit") or 0
|
||||
device_limit = tariff.get("device_limit") or 0
|
||||
subgroup_title = tariff.get("subgroup_title", "")
|
||||
traffic_text = "безлимит" if traffic_limit <= 0 else f"{traffic_limit} ГБ"
|
||||
devices_text = "безлимит" if device_limit <= 0 else str(device_limit)
|
||||
lines = []
|
||||
if subgroup_title:
|
||||
lines.append(subgroup_title)
|
||||
lines.append(f"Трафик: {traffic_text}")
|
||||
lines.append(f"Устройств: {devices_text}")
|
||||
tariff_details = "\n" + "\n".join(lines)
|
||||
try:
|
||||
name, subgroup_title, traffic_limit_gb, device_limit, _ = await get_key_tariff_display(
|
||||
session=session,
|
||||
key_record=record,
|
||||
)
|
||||
if name:
|
||||
tariff_name = name
|
||||
except Exception as error:
|
||||
logger.warning(f"[NOTIFY] Ошибка при получении тарифных лимитов для {email}: {error}")
|
||||
|
||||
traffic_text = "безлимит" if traffic_limit_gb == 0 else f"{traffic_limit_gb} ГБ"
|
||||
devices_text = "безлимит" if device_limit == 0 else str(device_limit)
|
||||
|
||||
lines = []
|
||||
if subgroup_title:
|
||||
lines.append(subgroup_title)
|
||||
lines.append(f"Трафик: {traffic_text}")
|
||||
lines.append(f"Устройств: {devices_text}")
|
||||
tariff_details = "\n" + "\n".join(lines) if lines else ""
|
||||
|
||||
return {
|
||||
"hours_left_formatted": hours_left_formatted,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
ALLOWED_TEMP_PAYMENT_STATES = {
|
||||
"waiting_for_payment",
|
||||
"waiting_for_renewal_payment",
|
||||
"waiting_for_gift_payment",
|
||||
"waiting_for_addons_payment",
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -11,6 +11,7 @@ from handlers.payments.currency_rates import format_for_user
|
||||
from handlers.texts import DEFAULT_PAYMENT_MESSAGE
|
||||
from handlers.utils import edit_or_send_message
|
||||
from logger import logger
|
||||
from ..constants import ALLOWED_TEMP_PAYMENT_STATES
|
||||
|
||||
from .service import (
|
||||
HELEKET_PAYMENT_METHODS,
|
||||
@@ -52,12 +53,7 @@ async def handle_custom_amount_input_heleket(
|
||||
tg_id = from_user.id
|
||||
|
||||
temp_data = await get_temporary_data(session, tg_id)
|
||||
valid_states = [
|
||||
"waiting_for_payment",
|
||||
"waiting_for_renewal_payment",
|
||||
"waiting_for_gift_payment",
|
||||
]
|
||||
if not temp_data or temp_data["state"] not in valid_states:
|
||||
if not temp_data or temp_data["state"] not in ALLOWED_TEMP_PAYMENT_STATES:
|
||||
await edit_or_send_message(
|
||||
target_message=message,
|
||||
text="❌ Не удалось получить данные для оплаты.",
|
||||
|
||||
@@ -11,6 +11,7 @@ from handlers.payments.currency_rates import format_for_user
|
||||
from handlers.texts import DEFAULT_PAYMENT_MESSAGE
|
||||
from handlers.utils import edit_or_send_message
|
||||
from logger import logger
|
||||
from ..constants import ALLOWED_TEMP_PAYMENT_STATES
|
||||
|
||||
from .service import (
|
||||
KASSAI_PAYMENT_METHODS,
|
||||
@@ -72,12 +73,7 @@ async def _handle_custom_amount_input_kassai(
|
||||
tg_id = from_user.id
|
||||
|
||||
temp_data = await get_temporary_data(session, tg_id)
|
||||
valid_states = [
|
||||
"waiting_for_payment",
|
||||
"waiting_for_renewal_payment",
|
||||
"waiting_for_gift_payment",
|
||||
]
|
||||
if not temp_data or temp_data["state"] not in valid_states:
|
||||
if not temp_data or temp_data["state"] not in ALLOWED_TEMP_PAYMENT_STATES:
|
||||
await edit_or_send_message(
|
||||
target_message=message,
|
||||
text="❌ Не удалось получить данные для оплаты.",
|
||||
|
||||
@@ -15,11 +15,11 @@ from handlers.payments.keyboards import (
|
||||
pay_keyboard as build_pay_keyboard,
|
||||
payment_options_for_user,
|
||||
)
|
||||
|
||||
from handlers.texts import DEFAULT_PAYMENT_MESSAGE, ENTER_SUM
|
||||
from handlers.payments.currency_rates import format_for_user
|
||||
from handlers.utils import edit_or_send_message
|
||||
from logger import logger
|
||||
from ..constants import ALLOWED_TEMP_PAYMENT_STATES
|
||||
|
||||
from .service import create_and_store_robokassa_payment
|
||||
|
||||
@@ -117,7 +117,7 @@ async def handle_custom_amount_input(
|
||||
from_user = event.from_user
|
||||
tg_id = from_user.id
|
||||
temp_data = await get_temporary_data(session, tg_id)
|
||||
if not temp_data or temp_data["state"] not in ["waiting_for_payment", "waiting_for_renewal_payment", "waiting_for_gift_payment"]:
|
||||
if not temp_data or temp_data["state"] not in ALLOWED_TEMP_PAYMENT_STATES:
|
||||
await edit_or_send_message(target_message=message, text="❌ Не удалось получить данные для оплаты.")
|
||||
return
|
||||
amount = int(temp_data["data"].get("required_amount", 0))
|
||||
@@ -153,8 +153,7 @@ async def handle_custom_amount_input(
|
||||
|
||||
await edit_or_send_message(target_message=message, text=text_out, reply_markup=markup)
|
||||
except Exception as e:
|
||||
from logger import logger as _lg
|
||||
_lg.error(f"Ошибка при создании платежа для пользователя {tg_id}: {e}")
|
||||
logger.error(f"Ошибка при создании платежа для пользователя {tg_id}: {e}")
|
||||
await edit_or_send_message(
|
||||
target_message=message,
|
||||
text="Произошла ошибка при создании платежа. Попробуйте позже.",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
-1
@@ -44,7 +44,7 @@ from handlers.buttons import (
|
||||
from handlers.captcha import generate_captcha
|
||||
from handlers.coupons import activate_coupon
|
||||
from handlers.instructions.instructions import send_instructions
|
||||
from handlers.keys.key_mode.key_create import confirm_create_new_key
|
||||
from handlers.keys.key_create import confirm_create_new_key
|
||||
from handlers.keys.key_view import process_callback_or_message_view_keys
|
||||
from handlers.payments.gift import handle_gift_link
|
||||
from handlers.profile import process_callback_view_profile
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from aiogram import Router
|
||||
|
||||
from .addons.key_addons import router as addons_router
|
||||
|
||||
|
||||
router = Router(name="keys_main_router")
|
||||
|
||||
router.include_routers(addons_router)
|
||||
@@ -0,0 +1,35 @@
|
||||
from aiogram import Router
|
||||
from aiogram.types import CallbackQuery
|
||||
|
||||
from core.settings.tariffs_config import TARIFFS_CONFIG
|
||||
|
||||
from . import key_addons_main, key_addons_pack
|
||||
from .utils import (
|
||||
UNLIMITED_DEVICES_LABEL,
|
||||
UNLIMITED_TRAFFIC_LABEL,
|
||||
KeyAddonConfigState,
|
||||
build_addons_screen_text,
|
||||
format_devices_label,
|
||||
format_traffic_label,
|
||||
is_not_downgrade,
|
||||
)
|
||||
|
||||
|
||||
def is_pack_mode_enabled() -> bool:
|
||||
return bool(TARIFFS_CONFIG.get("KEY_ADDONS_PACK_MODE"))
|
||||
|
||||
|
||||
def _main_mode_filter(callback: CallbackQuery, *args, **kwargs) -> bool:
|
||||
return not is_pack_mode_enabled()
|
||||
|
||||
|
||||
def _pack_mode_filter(callback: CallbackQuery, *args, **kwargs) -> bool:
|
||||
return is_pack_mode_enabled()
|
||||
|
||||
|
||||
key_addons_main.router.callback_query.filter(_main_mode_filter)
|
||||
key_addons_pack.router.callback_query.filter(_pack_mode_filter)
|
||||
|
||||
router = Router()
|
||||
router.include_router(key_addons_main.router)
|
||||
router.include_router(key_addons_pack.router)
|
||||
@@ -0,0 +1,940 @@
|
||||
from math import ceil
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import USE_NEW_PAYMENT_FLOW
|
||||
from core.settings.tariffs_config import TARIFFS_CONFIG, normalize_tariff_config
|
||||
from database import get_balance, get_key_details, get_tariff_by_id, save_key_config_with_mode, update_balance
|
||||
from handlers.buttons import (
|
||||
BACK,
|
||||
CONFIRM_ADDON_BUTTON_TEXT,
|
||||
DOWNGRADE_ADDON_BUTTON_TEXT,
|
||||
DOWNGRADE_CONFIRM_BUTTON_TEXT,
|
||||
PAYMENT,
|
||||
)
|
||||
from handlers.keys.key_view import render_key_info
|
||||
from handlers.payments.currency_rates import format_for_user
|
||||
from handlers.payments.fast_payment_flow import try_fast_payment_flow
|
||||
from handlers.tariffs.tariff_display import GB, get_effective_limits_for_key
|
||||
from handlers.texts import (
|
||||
ADDONS_APPLIED_TEXT,
|
||||
DOWNGRADE_INLINE_WARNING_TEXT,
|
||||
DOWNGRADE_SAVED_TEXT,
|
||||
DOWNGRADE_WARNING_TEXT,
|
||||
INSUFFICIENT_FUNDS_RENEWAL_MSG,
|
||||
NO_EXTRA_PAYMENT_TEXT,
|
||||
UNLIMITED_DEVICES_LABEL,
|
||||
UNLIMITED_TRAFFIC_LABEL,
|
||||
)
|
||||
from handlers.utils import edit_or_send_message
|
||||
from logger import logger
|
||||
|
||||
from ..buy.key_tariffs import calculate_config_price
|
||||
from .utils import (
|
||||
KeyAddonConfigState,
|
||||
build_addons_screen_text,
|
||||
format_devices_label,
|
||||
format_traffic_label,
|
||||
is_not_downgrade,
|
||||
)
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
async def render_addons_screen(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
data = await state.get_data()
|
||||
email = data.get("addon_key_email")
|
||||
tariff_id = data.get("addon_tariff_id")
|
||||
cfg = data.get("addon_tariff_config") or {}
|
||||
|
||||
logger.debug(
|
||||
f"[ADDONS] render_addons_screen start: tg_id={callback.from_user.id} "
|
||||
f"email={email} tariff_id={tariff_id} data={data}"
|
||||
)
|
||||
|
||||
current_devices = data.get("addon_current_device_limit")
|
||||
current_traffic_gb = data.get("addon_current_traffic_gb")
|
||||
original_price = int(data.get("addon_original_price") or 0)
|
||||
|
||||
selected_devices = data.get("addon_selected_device_limit")
|
||||
selected_traffic_gb = data.get("addon_selected_traffic_gb")
|
||||
|
||||
if not email or not tariff_id:
|
||||
logger.warning(f"[ADDONS] Нет email или tariff_id в состоянии: {data}")
|
||||
await callback.message.answer("❌ Данные для изменения подписки не найдены.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
tariff = await get_tariff_by_id(session, int(tariff_id))
|
||||
if not tariff:
|
||||
logger.error(f"[ADDONS] Тариф {tariff_id} не найден в render_addons_screen")
|
||||
await callback.message.answer("❌ Тариф не найден.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
tariff_name = tariff.get("name") or "подписка"
|
||||
|
||||
raw_device_options = cfg.get("device_options") or tariff.get("device_options") or []
|
||||
raw_traffic_options = cfg.get("traffic_options_gb") or tariff.get("traffic_options_gb") or []
|
||||
|
||||
try:
|
||||
device_options = sorted(
|
||||
raw_device_options,
|
||||
key=lambda v: (int(v) == 0, int(v)),
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
device_options = raw_device_options
|
||||
|
||||
try:
|
||||
traffic_options = sorted(
|
||||
raw_traffic_options,
|
||||
key=lambda v: (int(v) == 0, int(v)),
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
traffic_options = raw_traffic_options
|
||||
|
||||
device_int_options: list[int] = []
|
||||
for value in device_options:
|
||||
try:
|
||||
device_int_options.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
traffic_int_options: list[int] = []
|
||||
for value in traffic_options:
|
||||
try:
|
||||
traffic_int_options.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
has_device_option = bool(device_int_options)
|
||||
has_device_choice = len(device_int_options) > 1
|
||||
|
||||
has_traffic_option = bool(traffic_int_options)
|
||||
has_traffic_choice = len(traffic_int_options) > 1
|
||||
|
||||
if selected_devices is None and has_device_option:
|
||||
if current_devices is not None and int(current_devices) in device_int_options:
|
||||
selected_devices = int(current_devices)
|
||||
elif device_int_options:
|
||||
selected_devices = device_int_options[0]
|
||||
|
||||
if selected_traffic_gb is None and has_traffic_option:
|
||||
if current_traffic_gb is not None and int(current_traffic_gb) in traffic_int_options:
|
||||
selected_traffic_gb = int(current_traffic_gb)
|
||||
elif traffic_int_options:
|
||||
selected_traffic_gb = traffic_int_options[0]
|
||||
|
||||
logger.debug(
|
||||
"[ADDONS] Limits before price: "
|
||||
f"current_devices={current_devices} current_traffic_gb={current_traffic_gb} "
|
||||
f"selected_devices={selected_devices} selected_traffic_gb={selected_traffic_gb} "
|
||||
f"original_price={original_price}"
|
||||
)
|
||||
|
||||
await state.update_data(
|
||||
addon_selected_device_limit=selected_devices,
|
||||
addon_selected_traffic_gb=selected_traffic_gb,
|
||||
)
|
||||
|
||||
current_devices_for_price = int(current_devices) if current_devices is not None and has_device_option else None
|
||||
current_traffic_for_price = (
|
||||
int(current_traffic_gb) if current_traffic_gb is not None and has_traffic_option else None
|
||||
)
|
||||
base_price_for_current = calculate_config_price(
|
||||
tariff=tariff,
|
||||
selected_device_limit=current_devices_for_price,
|
||||
selected_traffic_gb=current_traffic_for_price,
|
||||
)
|
||||
|
||||
total_price = calculate_config_price(
|
||||
tariff=tariff,
|
||||
selected_device_limit=int(selected_devices) if selected_devices is not None and has_device_option else None,
|
||||
selected_traffic_gb=int(selected_traffic_gb)
|
||||
if selected_traffic_gb is not None and has_traffic_option
|
||||
else None,
|
||||
)
|
||||
extra_price = max(0, total_price - base_price_for_current)
|
||||
|
||||
logger.debug(
|
||||
"[ADDONS] Calculated prices: "
|
||||
f"base_price_for_current={base_price_for_current} total_price={total_price} extra_price={extra_price} "
|
||||
f"has_device_option={has_device_option} has_traffic_option={has_traffic_option}"
|
||||
)
|
||||
|
||||
tg_id = callback.from_user.id
|
||||
language_code = getattr(callback.from_user, "language_code", None)
|
||||
|
||||
total_price_text = await format_for_user(session, tg_id, float(total_price), language_code)
|
||||
extra_price_text = await format_for_user(session, tg_id, float(extra_price), language_code)
|
||||
|
||||
current_devices_label = format_devices_label(current_devices)
|
||||
current_traffic_label = format_traffic_label(current_traffic_gb)
|
||||
new_devices_label = format_devices_label(selected_devices)
|
||||
new_traffic_label = format_traffic_label(selected_traffic_gb)
|
||||
|
||||
downgrade_warning = None
|
||||
devices_downgrade = False
|
||||
traffic_downgrade = False
|
||||
allow_downgrade = bool(TARIFFS_CONFIG.get("ALLOW_DOWNGRADE", True))
|
||||
|
||||
if allow_downgrade:
|
||||
if has_device_choice and current_devices is not None and selected_devices is not None:
|
||||
devices_downgrade = not is_not_downgrade(current_devices, selected_devices)
|
||||
if has_traffic_choice and current_traffic_gb is not None and selected_traffic_gb is not None:
|
||||
traffic_downgrade = not is_not_downgrade(current_traffic_gb, selected_traffic_gb)
|
||||
if devices_downgrade or traffic_downgrade:
|
||||
new_limits_parts = []
|
||||
if has_device_choice:
|
||||
new_limits_parts.append(new_devices_label)
|
||||
if has_traffic_choice:
|
||||
new_limits_parts.append(new_traffic_label)
|
||||
new_limits_desc = ", ".join(new_limits_parts) if new_limits_parts else "выбранные параметры"
|
||||
downgrade_warning = DOWNGRADE_INLINE_WARNING_TEXT.format(
|
||||
total_price_text=total_price_text,
|
||||
new_limits_desc=new_limits_desc,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"[ADDONS] Downgrade flags: "
|
||||
f"devices_downgrade={devices_downgrade} traffic_downgrade={traffic_downgrade} "
|
||||
f"ALLOW_DOWNGRADE={allow_downgrade}"
|
||||
)
|
||||
|
||||
text = build_addons_screen_text(
|
||||
tariff_name=tariff_name,
|
||||
current_devices_label=current_devices_label,
|
||||
current_traffic_label=current_traffic_label,
|
||||
new_devices_label=new_devices_label,
|
||||
new_traffic_label=new_traffic_label,
|
||||
has_device_choice=has_device_choice,
|
||||
has_traffic_choice=has_traffic_choice,
|
||||
total_price_text=total_price_text,
|
||||
extra_price_text=extra_price_text,
|
||||
downgrade_warning=downgrade_warning,
|
||||
)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
device_buttons = []
|
||||
traffic_buttons = []
|
||||
|
||||
if has_device_choice:
|
||||
for value in device_int_options:
|
||||
if not allow_downgrade and not is_not_downgrade(current_devices, value):
|
||||
continue
|
||||
is_selected = selected_devices is not None and int(value) == int(selected_devices)
|
||||
mark = " ✅" if is_selected else ""
|
||||
if value == 0:
|
||||
caption = f"{UNLIMITED_DEVICES_LABEL.capitalize()}{mark}"
|
||||
else:
|
||||
caption = f"{value} устройств{mark}"
|
||||
device_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=caption,
|
||||
callback_data=f"key_addons_devices|{email}|{value}",
|
||||
)
|
||||
)
|
||||
|
||||
if has_traffic_choice:
|
||||
for value in traffic_int_options:
|
||||
if not allow_downgrade and not is_not_downgrade(current_traffic_gb, value):
|
||||
continue
|
||||
is_selected = selected_traffic_gb is not None and int(selected_traffic_gb) == int(value)
|
||||
mark = " ✅" if is_selected else ""
|
||||
if value == 0:
|
||||
caption = f"{UNLIMITED_TRAFFIC_LABEL.capitalize()}{mark}"
|
||||
else:
|
||||
caption = f"{value} ГБ{mark}"
|
||||
traffic_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=caption,
|
||||
callback_data=f"key_addons_traffic|{email}|{value}",
|
||||
)
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"[ADDONS] Buttons: devices={[b.text for b in device_buttons]} traffic={[b.text for b in traffic_buttons]}"
|
||||
)
|
||||
|
||||
if device_buttons and traffic_buttons:
|
||||
max_len = max(len(device_buttons), len(traffic_buttons))
|
||||
for i in range(max_len):
|
||||
row = []
|
||||
if i < len(device_buttons):
|
||||
row.append(device_buttons[i])
|
||||
if i < len(traffic_buttons):
|
||||
row.append(traffic_buttons[i])
|
||||
builder.row(*row)
|
||||
elif device_buttons:
|
||||
for button in device_buttons:
|
||||
builder.row(button)
|
||||
elif traffic_buttons:
|
||||
for button in traffic_buttons:
|
||||
builder.row(button)
|
||||
|
||||
if allow_downgrade and (devices_downgrade or traffic_downgrade):
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=DOWNGRADE_ADDON_BUTTON_TEXT,
|
||||
callback_data="key_addons_downgrade",
|
||||
)
|
||||
)
|
||||
else:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=CONFIRM_ADDON_BUTTON_TEXT.format(amount=extra_price_text),
|
||||
callback_data="key_addons_confirm",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback.message,
|
||||
text=text,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("key_addons|"))
|
||||
async def start_key_addons(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
email = callback.data.split("|")[1]
|
||||
logger.debug(f"[ADDONS] start_key_addons: tg_id={callback.from_user.id} email={email}")
|
||||
|
||||
record = await get_key_details(session, email)
|
||||
if not record:
|
||||
logger.warning(f"[ADDONS] Подписка {email} не найдена")
|
||||
await callback.message.answer("❌ Подписка не найдена.")
|
||||
return
|
||||
|
||||
tariff_id = record.get("tariff_id")
|
||||
if not tariff_id:
|
||||
logger.warning(f"[ADDONS] Для подписки {email} не назначен тариф")
|
||||
await callback.message.answer("❌ Для этой подписки тариф не назначен, расширение недоступно.")
|
||||
return
|
||||
|
||||
tariff = await get_tariff_by_id(session, int(tariff_id))
|
||||
if not tariff:
|
||||
logger.error(f"[ADDONS] Тариф {tariff_id} не найден для email={email}")
|
||||
await callback.message.answer("❌ Тариф не найден.")
|
||||
return
|
||||
|
||||
if not tariff.get("configurable"):
|
||||
logger.info(f"[ADDONS] Тариф {tariff_id} не конфигурируемый, расширение недоступно")
|
||||
await callback.message.answer("❌ Для этого тарифа расширение через конфигуратор недоступно.")
|
||||
return
|
||||
|
||||
cfg = normalize_tariff_config(tariff)
|
||||
|
||||
raw_device_options = cfg.get("device_options") or tariff.get("device_options") or []
|
||||
raw_traffic_options = cfg.get("traffic_options_gb") or tariff.get("traffic_options_gb") or []
|
||||
|
||||
device_int_options: list[int] = []
|
||||
for value in raw_device_options:
|
||||
try:
|
||||
device_int_options.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
traffic_int_options: list[int] = []
|
||||
for value in raw_traffic_options:
|
||||
try:
|
||||
traffic_int_options.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
base_device_limit = cfg.get("base_device_limit")
|
||||
if base_device_limit is None:
|
||||
base_device_limit = tariff.get("device_limit")
|
||||
if base_device_limit is not None:
|
||||
try:
|
||||
base_device_int = int(base_device_limit)
|
||||
if base_device_int not in device_int_options:
|
||||
raw_device_options.append(base_device_int)
|
||||
device_int_options.append(base_device_int)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
device_overrides_cfg = cfg.get("device_price_overrides") or tariff.get("device_overrides") or {}
|
||||
if "0" in device_overrides_cfg and 0 not in device_int_options:
|
||||
raw_device_options.append(0)
|
||||
device_int_options.append(0)
|
||||
|
||||
base_traffic_gb = cfg.get("base_traffic_gb")
|
||||
if base_traffic_gb is None:
|
||||
raw_limit = tariff.get("traffic_limit")
|
||||
if raw_limit:
|
||||
raw_limit = int(raw_limit)
|
||||
if raw_limit >= GB:
|
||||
base_traffic_gb = int(raw_limit / GB)
|
||||
else:
|
||||
base_traffic_gb = raw_limit
|
||||
if base_traffic_gb is not None:
|
||||
try:
|
||||
base_traffic_int = int(base_traffic_gb)
|
||||
if base_traffic_int not in traffic_int_options:
|
||||
raw_traffic_options.append(base_traffic_int)
|
||||
traffic_int_options.append(base_traffic_int)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
traffic_overrides_cfg = cfg.get("traffic_price_overrides") or tariff.get("traffic_overrides") or {}
|
||||
if "0" in traffic_overrides_cfg and 0 not in traffic_int_options:
|
||||
raw_traffic_options.append(0)
|
||||
traffic_int_options.append(0)
|
||||
|
||||
try:
|
||||
device_options = sorted(
|
||||
raw_device_options,
|
||||
key=lambda v: (int(v) == 0, int(v)),
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
device_options = raw_device_options
|
||||
|
||||
try:
|
||||
traffic_options = sorted(
|
||||
raw_traffic_options,
|
||||
key=lambda v: (int(v) == 0, int(v)),
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
traffic_options = raw_traffic_options
|
||||
|
||||
logger.info(
|
||||
"[ADDONS] start_key_addons options: "
|
||||
f"email={email} tariff_id={tariff_id} "
|
||||
f"device_options={device_options} traffic_options={traffic_options}"
|
||||
)
|
||||
|
||||
if not device_options and not traffic_options:
|
||||
logger.warning(f"[ADDONS] Пустой конфигуратор для тарифа {tariff_id}")
|
||||
await callback.message.answer("❌ Конфигуратор для этого тарифа не настроен.")
|
||||
return
|
||||
|
||||
selected_device_limit_db = record.get("selected_device_limit")
|
||||
selected_traffic_limit_db = record.get("selected_traffic_limit")
|
||||
original_price_db = record.get("selected_price_rub")
|
||||
current_device_limit_db = record.get("current_device_limit")
|
||||
current_traffic_limit_db = record.get("current_traffic_limit")
|
||||
|
||||
base_devices = tariff.get("device_limit")
|
||||
base_devices = int(base_devices) if base_devices is not None else None
|
||||
|
||||
base_traffic_bytes = tariff.get("traffic_limit")
|
||||
base_traffic_gb_from_tariff = int(base_traffic_bytes / GB) if base_traffic_bytes else None
|
||||
|
||||
current_devices = (
|
||||
int(current_device_limit_db)
|
||||
if current_device_limit_db is not None
|
||||
else (int(selected_device_limit_db) if selected_device_limit_db is not None else base_devices)
|
||||
)
|
||||
current_traffic_gb = (
|
||||
int(current_traffic_limit_db)
|
||||
if current_traffic_limit_db is not None
|
||||
else (int(selected_traffic_limit_db) if selected_traffic_limit_db is not None else base_traffic_gb_from_tariff)
|
||||
)
|
||||
|
||||
current_devices_for_price = int(current_devices) if current_devices is not None else None
|
||||
current_traffic_gb_for_price = int(current_traffic_gb) if current_traffic_gb is not None else None
|
||||
|
||||
config_price_for_current = calculate_config_price(
|
||||
tariff=tariff,
|
||||
selected_device_limit=current_devices_for_price,
|
||||
selected_traffic_gb=current_traffic_gb_for_price,
|
||||
)
|
||||
|
||||
try:
|
||||
original_price_from_db = int(original_price_db) if original_price_db is not None else 0
|
||||
except (TypeError, ValueError):
|
||||
original_price_from_db = 0
|
||||
|
||||
original_price = original_price_from_db or int(config_price_for_current)
|
||||
|
||||
logger.debug(
|
||||
"[ADDONS] start_key_addons state: "
|
||||
f"email={email} tariff_id={tariff_id} current_devices={current_devices} current_traffic_gb={current_traffic_gb} "
|
||||
f"config_price_for_current={config_price_for_current} original_price_db={original_price_db} "
|
||||
f"original_price={original_price}"
|
||||
)
|
||||
|
||||
cfg_for_state = dict(cfg)
|
||||
if device_options:
|
||||
cfg_for_state["device_options"] = device_options
|
||||
if traffic_options:
|
||||
cfg_for_state["traffic_options_gb"] = traffic_options
|
||||
|
||||
await state.update_data(
|
||||
addon_key_email=email,
|
||||
addon_tariff_id=int(tariff_id),
|
||||
addon_tariff_config=cfg_for_state,
|
||||
addon_current_device_limit=current_devices,
|
||||
addon_current_traffic_gb=current_traffic_gb,
|
||||
addon_original_price=original_price,
|
||||
addon_selected_device_limit=current_devices,
|
||||
addon_selected_traffic_gb=current_traffic_gb,
|
||||
)
|
||||
await state.set_state(KeyAddonConfigState.configuring)
|
||||
|
||||
await render_addons_screen(callback, state, session)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("key_addons_devices|"), KeyAddonConfigState.configuring)
|
||||
async def handle_addons_devices_choice(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
parts = callback.data.split("|", 2)
|
||||
if len(parts) < 3:
|
||||
await callback.answer("Некорректные данные", show_alert=True)
|
||||
return
|
||||
|
||||
new_devices = int(parts[2])
|
||||
data = await state.get_data()
|
||||
current_devices = data.get("addon_current_device_limit")
|
||||
selected_devices = data.get("addon_selected_device_limit")
|
||||
|
||||
logger.debug(
|
||||
"[ADDONS] handle_addons_devices_choice: "
|
||||
f"tg_id={callback.from_user.id} email={data.get('addon_key_email')} "
|
||||
f"new_devices={new_devices} current_devices={current_devices} selected_devices={selected_devices}"
|
||||
)
|
||||
|
||||
if selected_devices is not None and int(selected_devices) == new_devices:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
allow_downgrade = bool(TARIFFS_CONFIG.get("ALLOW_DOWNGRADE", True))
|
||||
if not allow_downgrade and not is_not_downgrade(current_devices, new_devices):
|
||||
await callback.answer("Нельзя снижать лимит устройств, только увеличивать.", show_alert=True)
|
||||
return
|
||||
|
||||
await state.update_data(addon_selected_device_limit=new_devices)
|
||||
await render_addons_screen(callback, state, session)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("key_addons_traffic|"), KeyAddonConfigState.configuring)
|
||||
async def handle_addons_traffic_choice(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
parts = callback.data.split("|", 2)
|
||||
if len(parts) < 3:
|
||||
await callback.answer("Некорректные данные", show_alert=True)
|
||||
return
|
||||
|
||||
new_traffic_gb = int(parts[2])
|
||||
|
||||
data = await state.get_data()
|
||||
current_traffic_gb = data.get("addon_current_traffic_gb")
|
||||
selected_traffic_gb = data.get("addon_selected_traffic_gb")
|
||||
|
||||
logger.debug(
|
||||
"[ADDONS] handle_addons_traffic_choice: "
|
||||
f"tg_id={callback.from_user.id} email={data.get('addon_key_email')} "
|
||||
f"new_traffic_gb={new_traffic_gb} current_traffic_gb={current_traffic_gb} "
|
||||
f"selected_traffic_gb={selected_traffic_gb}"
|
||||
)
|
||||
|
||||
if selected_traffic_gb is not None and int(selected_traffic_gb) == new_traffic_gb:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
allow_downgrade = bool(TARIFFS_CONFIG.get("ALLOW_DOWNGRADE", True))
|
||||
if not allow_downgrade and not is_not_downgrade(current_traffic_gb, new_traffic_gb):
|
||||
await callback.answer("Нельзя снижать лимит трафика, только увеличивать.", show_alert=True)
|
||||
return
|
||||
|
||||
await state.update_data(addon_selected_traffic_gb=new_traffic_gb)
|
||||
await render_addons_screen(callback, state, session)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "key_addons_downgrade", KeyAddonConfigState.configuring)
|
||||
async def handle_addons_downgrade(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
tg_id = callback.from_user.id
|
||||
data = await state.get_data()
|
||||
|
||||
email = data.get("addon_key_email")
|
||||
tariff_id = data.get("addon_tariff_id")
|
||||
selected_devices = data.get("addon_selected_device_limit")
|
||||
selected_traffic_gb = data.get("addon_selected_traffic_gb")
|
||||
current_devices = data.get("addon_current_device_limit")
|
||||
current_traffic_gb = data.get("addon_current_traffic_gb")
|
||||
|
||||
logger.info(
|
||||
"[ADDONS] handle_addons_downgrade: "
|
||||
f"tg_id={tg_id} email={email} tariff_id={tariff_id} "
|
||||
f"selected_devices={selected_devices} selected_traffic_gb={selected_traffic_gb} "
|
||||
f"current_devices={current_devices} current_traffic_gb={current_traffic_gb}"
|
||||
)
|
||||
|
||||
if not email or not tariff_id:
|
||||
await callback.message.answer("❌ Данные для изменения подписки не найдены.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
tariff = await get_tariff_by_id(session, int(tariff_id))
|
||||
if not tariff:
|
||||
logger.error(f"[ADDONS] Тариф {tariff_id} не найден в handle_addons_downgrade")
|
||||
await callback.message.answer("❌ Тариф не найден.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
cfg = data.get("addon_tariff_config") or {}
|
||||
device_options = cfg.get("device_options") or []
|
||||
traffic_options = cfg.get("traffic_options_gb") or []
|
||||
has_device_option = bool(device_options)
|
||||
has_traffic_option = bool(traffic_options)
|
||||
has_device_choice = len(device_options) > 1
|
||||
has_traffic_choice = len(traffic_options) > 1
|
||||
|
||||
total_price = calculate_config_price(
|
||||
tariff=tariff,
|
||||
selected_device_limit=int(selected_devices) if selected_devices is not None and has_device_option else None,
|
||||
selected_traffic_gb=int(selected_traffic_gb)
|
||||
if selected_traffic_gb is not None and has_traffic_option
|
||||
else None,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"[ADDONS] Downgrade price: "
|
||||
f"total_price={total_price} has_device_option={has_device_option} has_traffic_option={has_traffic_option}"
|
||||
)
|
||||
|
||||
language_code = getattr(callback.from_user, "language_code", None)
|
||||
total_price_text = await format_for_user(session, tg_id, float(total_price), language_code)
|
||||
|
||||
current_devices_label = format_devices_label(current_devices)
|
||||
current_traffic_label = format_traffic_label(current_traffic_gb)
|
||||
new_devices_label = format_devices_label(selected_devices)
|
||||
new_traffic_label = format_traffic_label(selected_traffic_gb)
|
||||
|
||||
text = DOWNGRADE_WARNING_TEXT.format(
|
||||
tariff_name=tariff.get("name") or "подписка",
|
||||
current_devices_label=current_devices_label if has_device_choice else "по умолчанию",
|
||||
current_traffic_label=current_traffic_label if has_traffic_choice else "по умолчанию",
|
||||
new_devices_label=new_devices_label if has_device_choice else "по умолчанию",
|
||||
new_traffic_label=new_traffic_label if has_traffic_choice else "по умолчанию",
|
||||
total_price_text=total_price_text,
|
||||
)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=DOWNGRADE_CONFIRM_BUTTON_TEXT,
|
||||
callback_data="key_addons_downgrade_apply",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"key_addons|{email}"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback.message,
|
||||
text=text,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "key_addons_downgrade_apply", KeyAddonConfigState.configuring)
|
||||
async def handle_addons_downgrade_apply(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
data = await state.get_data()
|
||||
|
||||
email = data.get("addon_key_email")
|
||||
tariff_id = data.get("addon_tariff_id")
|
||||
selected_devices = data.get("addon_selected_device_limit")
|
||||
selected_traffic_gb = data.get("addon_selected_traffic_gb")
|
||||
|
||||
logger.info(
|
||||
"[ADDONS] handle_addons_downgrade_apply: "
|
||||
f"tg_id={callback.from_user.id} email={email} tariff_id={tariff_id} "
|
||||
f"selected_devices={selected_devices} selected_traffic_gb={selected_traffic_gb}"
|
||||
)
|
||||
|
||||
if not email or not tariff_id:
|
||||
await callback.message.answer("❌ Данные для изменения подписки не найдены.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
tariff = await get_tariff_by_id(session, int(tariff_id))
|
||||
if not tariff:
|
||||
logger.error(f"[ADDONS] Тариф {tariff_id} не найден в handle_addons_downgrade_apply")
|
||||
await callback.message.answer("❌ Тариф не найден.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
cfg = data.get("addon_tariff_config") or {}
|
||||
device_options = cfg.get("device_options") or []
|
||||
traffic_options = cfg.get("traffic_options_gb") or []
|
||||
has_device_option = bool(device_options)
|
||||
has_traffic_option = bool(traffic_options)
|
||||
has_device_choice = len(device_options) > 1
|
||||
has_traffic_choice = len(traffic_options) > 1
|
||||
|
||||
total_price = calculate_config_price(
|
||||
tariff=tariff,
|
||||
selected_device_limit=int(selected_devices) if selected_devices is not None and has_device_option else None,
|
||||
selected_traffic_gb=int(selected_traffic_gb)
|
||||
if selected_traffic_gb is not None and has_traffic_option
|
||||
else None,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"[ADDONS] Downgrade apply price: "
|
||||
f"total_price={total_price} has_device_choice={has_device_choice} has_traffic_choice={has_traffic_choice}"
|
||||
)
|
||||
|
||||
try:
|
||||
await save_key_config_with_mode(
|
||||
session=session,
|
||||
email=email,
|
||||
selected_devices=selected_devices,
|
||||
selected_traffic_gb=selected_traffic_gb,
|
||||
total_price=int(total_price),
|
||||
has_device_choice=has_device_choice,
|
||||
has_traffic_choice=has_traffic_choice,
|
||||
config_mode="downgrade",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as error:
|
||||
logger.error(f"[ADDONS] Ошибка при сохранении будущих условий для {email}: {error}")
|
||||
await callback.message.answer("❌ Ошибка при сохранении новых условий. Попробуйте позже.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
await render_key_info(callback.message, session, email, "img/pic_view.jpg")
|
||||
await callback.answer(DOWNGRADE_SAVED_TEXT, show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "key_addons_confirm", KeyAddonConfigState.configuring)
|
||||
async def handle_addons_confirm(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
from handlers.keys.operations import renew_key_in_cluster
|
||||
|
||||
tg_id = callback.from_user.id
|
||||
data = await state.get_data()
|
||||
|
||||
email = data.get("addon_key_email")
|
||||
tariff_id = data.get("addon_tariff_id")
|
||||
original_price = int(data.get("addon_original_price") or 0)
|
||||
selected_devices = data.get("addon_selected_device_limit")
|
||||
selected_traffic_gb = data.get("addon_selected_traffic_gb")
|
||||
current_devices = data.get("addon_current_device_limit")
|
||||
current_traffic_gb = data.get("addon_current_traffic_gb")
|
||||
|
||||
logger.info(
|
||||
"[ADDONS] handle_addons_confirm: "
|
||||
f"tg_id={tg_id} email={email} tariff_id={tariff_id} original_price={original_price} "
|
||||
f"selected_devices={selected_devices} selected_traffic_gb={selected_traffic_gb} "
|
||||
f"current_devices={current_devices} current_traffic_gb={current_traffic_gb}"
|
||||
)
|
||||
|
||||
if not email or not tariff_id:
|
||||
await callback.message.answer("❌ Данные для изменения подписки не найдены.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
record = await get_key_details(session, email)
|
||||
if not record:
|
||||
logger.warning(f"[ADDONS] Подписка {email} не найдена в handle_addons_confirm")
|
||||
await callback.message.answer("❌ Подписка не найдена.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
tariff = await get_tariff_by_id(session, int(tariff_id))
|
||||
if not tariff:
|
||||
logger.error(f"[ADDONS] Тариф {tariff_id} не найден в handle_addons_confirm")
|
||||
await callback.message.answer("❌ Тариф не найден.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
cfg = data.get("addon_tariff_config") or {}
|
||||
device_options = cfg.get("device_options") or []
|
||||
traffic_options = cfg.get("traffic_options_gb") or []
|
||||
has_device_option = bool(device_options)
|
||||
has_traffic_option = bool(traffic_options)
|
||||
has_device_choice = len(device_options) > 1
|
||||
has_traffic_choice = len(traffic_options) > 1
|
||||
|
||||
current_devices_for_price = int(current_devices) if current_devices is not None and has_device_option else None
|
||||
current_traffic_for_price = (
|
||||
int(current_traffic_gb) if current_traffic_gb is not None and has_traffic_option else None
|
||||
)
|
||||
base_price_for_current = calculate_config_price(
|
||||
tariff=tariff,
|
||||
selected_device_limit=current_devices_for_price,
|
||||
selected_traffic_gb=current_traffic_for_price,
|
||||
)
|
||||
|
||||
total_price = calculate_config_price(
|
||||
tariff=tariff,
|
||||
selected_device_limit=int(selected_devices) if selected_devices is not None and has_device_option else None,
|
||||
selected_traffic_gb=int(selected_traffic_gb)
|
||||
if selected_traffic_gb is not None and has_traffic_option
|
||||
else None,
|
||||
)
|
||||
extra_price = max(0, total_price - base_price_for_current)
|
||||
|
||||
logger.debug(
|
||||
"[ADDONS] Confirm prices: "
|
||||
f"base_price_for_current={base_price_for_current} total_price={total_price} "
|
||||
f"original_price={original_price} extra_price={extra_price} "
|
||||
f"has_device_option={has_device_option} has_traffic_option={has_traffic_option}"
|
||||
)
|
||||
|
||||
allow_downgrade = bool(TARIFFS_CONFIG.get("ALLOW_DOWNGRADE", True))
|
||||
devices_downgrade = (
|
||||
allow_downgrade
|
||||
and has_device_choice
|
||||
and current_devices is not None
|
||||
and selected_devices is not None
|
||||
and not is_not_downgrade(current_devices, selected_devices)
|
||||
)
|
||||
traffic_downgrade = (
|
||||
allow_downgrade
|
||||
and has_traffic_choice
|
||||
and current_traffic_gb is not None
|
||||
and selected_traffic_gb is not None
|
||||
and not is_not_downgrade(current_traffic_gb, selected_traffic_gb)
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"[ADDONS] Confirm downgrade flags: devices_downgrade={devices_downgrade} traffic_downgrade={traffic_downgrade}"
|
||||
)
|
||||
|
||||
if devices_downgrade or traffic_downgrade:
|
||||
await handle_addons_downgrade(callback, state, session)
|
||||
return
|
||||
|
||||
if extra_price <= 0:
|
||||
logger.info(f"[ADDONS] extra_price <= 0, доплата не требуется, email={email}")
|
||||
await state.clear()
|
||||
await render_key_info(callback.message, session, email, "img/pic_view.jpg")
|
||||
await callback.answer(NO_EXTRA_PAYMENT_TEXT, show_alert=True)
|
||||
return
|
||||
|
||||
balance = await get_balance(session, tg_id)
|
||||
logger.debug(f"[ADDONS] Balance check: tg_id={tg_id} balance={balance} extra_price={extra_price}")
|
||||
|
||||
if balance < extra_price:
|
||||
required_amount = ceil(extra_price - balance)
|
||||
language_code = getattr(callback.from_user, "language_code", None)
|
||||
required_amount_text = await format_for_user(session, tg_id, float(required_amount), language_code)
|
||||
|
||||
logger.info(
|
||||
"[ADDONS] Недостаточно средств: "
|
||||
f"balance={balance} extra_price={extra_price} required_amount={required_amount} "
|
||||
f"tg_id={tg_id} USE_NEW_PAYMENT_FLOW={USE_NEW_PAYMENT_FLOW}"
|
||||
)
|
||||
|
||||
if USE_NEW_PAYMENT_FLOW:
|
||||
handled = await try_fast_payment_flow(
|
||||
callback,
|
||||
session,
|
||||
state,
|
||||
tg_id=tg_id,
|
||||
temp_key="waiting_for_addons_payment",
|
||||
temp_payload={
|
||||
"email": email,
|
||||
"tariff_id": int(tariff_id),
|
||||
"original_price": original_price,
|
||||
"selected_device_limit": selected_devices,
|
||||
"selected_traffic_gb": selected_traffic_gb,
|
||||
"current_device_limit": current_devices,
|
||||
"current_traffic_gb": current_traffic_gb,
|
||||
"required_amount": required_amount,
|
||||
},
|
||||
required_amount=required_amount,
|
||||
)
|
||||
logger.debug(f"[ADDONS] try_fast_payment_flow handled={handled} tg_id={tg_id} email={email}")
|
||||
if handled:
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
|
||||
await edit_or_send_message(
|
||||
target_message=callback.message,
|
||||
text=INSUFFICIENT_FUNDS_RENEWAL_MSG.format(required_amount=required_amount_text),
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
expiry_time = record["expiry_time"]
|
||||
client_id = record["client_id"]
|
||||
server_id = record["server_id"]
|
||||
|
||||
selected_traffic_gb_for_effective = (
|
||||
int(selected_traffic_gb) if selected_traffic_gb is not None and has_traffic_option else 0
|
||||
)
|
||||
|
||||
current_subgroup = None
|
||||
try:
|
||||
current_tariff_id = record.get("tariff_id")
|
||||
if current_tariff_id:
|
||||
current_tariff = await get_tariff_by_id(session, int(current_tariff_id))
|
||||
if current_tariff:
|
||||
current_subgroup = current_tariff.get("subgroup_title")
|
||||
except Exception as error:
|
||||
logger.warning(f"[ADDONS] Не удалось определить текущую подгруппу: {error}")
|
||||
|
||||
target_subgroup = tariff.get("subgroup_title")
|
||||
old_subgroup = current_subgroup
|
||||
|
||||
device_limit_effective_new, traffic_limit_bytes_effective_new = await get_effective_limits_for_key(
|
||||
session=session,
|
||||
tariff_id=int(tariff_id),
|
||||
selected_device_limit=int(selected_devices) if selected_devices is not None and has_device_option else None,
|
||||
selected_traffic_gb=selected_traffic_gb_for_effective,
|
||||
)
|
||||
traffic_limit_gb_effective = (
|
||||
int(traffic_limit_bytes_effective_new / GB) if traffic_limit_bytes_effective_new else 0
|
||||
)
|
||||
total_gb = traffic_limit_gb_effective
|
||||
hwid_device_limit_to_set = device_limit_effective_new
|
||||
|
||||
logger.debug(
|
||||
"[ADDONS] renew_key_in_cluster params: "
|
||||
f"server_id={server_id} email={email} client_id={client_id} total_gb={total_gb} "
|
||||
f"hwid_device_limit_to_set={hwid_device_limit_to_set} target_subgroup={target_subgroup} "
|
||||
f"old_subgroup={old_subgroup}"
|
||||
)
|
||||
|
||||
await renew_key_in_cluster(
|
||||
cluster_id=server_id,
|
||||
email=email,
|
||||
client_id=client_id,
|
||||
new_expiry_time=expiry_time,
|
||||
total_gb=total_gb,
|
||||
session=session,
|
||||
hwid_device_limit=hwid_device_limit_to_set,
|
||||
reset_traffic=False,
|
||||
target_subgroup=target_subgroup,
|
||||
old_subgroup=old_subgroup,
|
||||
plan=int(tariff_id),
|
||||
)
|
||||
|
||||
await save_key_config_with_mode(
|
||||
session=session,
|
||||
email=email,
|
||||
selected_devices=selected_devices,
|
||||
selected_traffic_gb=selected_traffic_gb,
|
||||
total_price=int(total_price),
|
||||
has_device_choice=has_device_choice,
|
||||
has_traffic_choice=has_traffic_choice,
|
||||
config_mode="addon",
|
||||
)
|
||||
|
||||
await update_balance(session, tg_id, -extra_price)
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
"[ADDONS] Успешное применение расширения: "
|
||||
f"tg_id={tg_id} email={email} total_price={total_price} extra_price={extra_price}"
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
await render_key_info(callback.message, session, email, "img/pic_view.jpg")
|
||||
await callback.answer(ADDONS_APPLIED_TEXT, show_alert=True)
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"[ADDONS] Ошибка при применении расширения подписки для {email}: {error}")
|
||||
await callback.message.answer("❌ Ошибка при обновлении подписки. Попробуйте позже.")
|
||||
await state.clear()
|
||||
@@ -0,0 +1,814 @@
|
||||
from math import ceil
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import USE_NEW_PAYMENT_FLOW
|
||||
from core.settings.tariffs_config import TARIFFS_CONFIG, normalize_tariff_config
|
||||
from database import (
|
||||
get_balance,
|
||||
get_key_details,
|
||||
get_tariff_by_id,
|
||||
save_key_config_with_mode,
|
||||
update_balance,
|
||||
)
|
||||
from handlers.buttons import BACK, CONFIRM_ADDON_BUTTON_TEXT, PAYMENT
|
||||
from handlers.keys.key_view import render_key_info
|
||||
from handlers.payments.currency_rates import format_for_user
|
||||
from handlers.payments.fast_payment_flow import try_fast_payment_flow
|
||||
from handlers.tariffs.tariff_display import GB, get_effective_limits_for_key
|
||||
from handlers.texts import (
|
||||
ADDONS_NO_EXTRA_PAYMENT_TEXT,
|
||||
ADDONS_PACK_SUCCESS_TEXT,
|
||||
INSUFFICIENT_FUNDS_RENEWAL_MSG,
|
||||
UNLIMITED_DEVICES_LABEL,
|
||||
UNLIMITED_TRAFFIC_LABEL,
|
||||
)
|
||||
from handlers.utils import edit_or_send_message
|
||||
from logger import logger
|
||||
|
||||
from ..buy.key_tariffs import calculate_config_price
|
||||
from .utils import (
|
||||
KeyAddonConfigState,
|
||||
build_addons_pack_screen_text,
|
||||
format_devices_label,
|
||||
format_traffic_label,
|
||||
)
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
def get_pack_flags() -> tuple[bool, bool, str]:
|
||||
mode = TARIFFS_CONFIG.get("KEY_ADDONS_PACK_MODE") or ""
|
||||
if not mode:
|
||||
return False, False, ""
|
||||
if mode == "traffic":
|
||||
return False, True, mode
|
||||
if mode == "devices":
|
||||
return True, False, mode
|
||||
if mode == "all":
|
||||
return True, True, mode
|
||||
|
||||
logger.warning(f"Некорректный KEY_ADDONS_PACK_MODE: {mode!r}")
|
||||
return False, False, mode
|
||||
|
||||
|
||||
async def render_addons_screen(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
data = await state.get_data()
|
||||
email = data.get("addon_key_email")
|
||||
tariff_id = data.get("addon_tariff_id")
|
||||
cfg = data.get("addon_tariff_config") or {}
|
||||
|
||||
logger.debug(
|
||||
f"[ADDONS] render_addons_screen PACK_MODE start: tg_id={callback.from_user.id} "
|
||||
f"email={email} tariff_id={tariff_id} data={data}"
|
||||
)
|
||||
|
||||
current_devices = data.get("addon_current_device_limit")
|
||||
current_traffic_gb = data.get("addon_current_traffic_gb")
|
||||
selected_devices = data.get("addon_selected_device_limit")
|
||||
selected_traffic_gb = data.get("addon_selected_traffic_gb")
|
||||
|
||||
if not email or not tariff_id:
|
||||
logger.warning(f"[ADDONS] PACK_MODE: нет email или tariff_id в состоянии: {data}")
|
||||
await callback.message.answer("❌ Данные для изменения подписки не найдены.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
tariff = await get_tariff_by_id(session, int(tariff_id))
|
||||
if not tariff:
|
||||
logger.error(f"[ADDONS] PACK_MODE: тариф {tariff_id} не найден в render_addons_screen")
|
||||
await callback.message.answer("❌ Тариф не найден.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
tariff_name = tariff.get("name") or "подписка"
|
||||
|
||||
raw_device_options = cfg.get("device_options") or tariff.get("device_options") or []
|
||||
raw_traffic_options = cfg.get("traffic_options_gb") or tariff.get("traffic_options_gb") or []
|
||||
|
||||
try:
|
||||
device_options = sorted(
|
||||
raw_device_options,
|
||||
key=lambda v: (int(v) == 0, int(v)),
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
device_options = raw_device_options
|
||||
|
||||
try:
|
||||
traffic_options = sorted(
|
||||
raw_traffic_options,
|
||||
key=lambda v: (int(v) == 0, int(v)),
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
traffic_options = raw_traffic_options
|
||||
|
||||
device_int_options: list[int] = []
|
||||
for value in device_options:
|
||||
try:
|
||||
device_int_options.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
traffic_int_options: list[int] = []
|
||||
for value in traffic_options:
|
||||
try:
|
||||
traffic_int_options.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
pack_devices, pack_traffic, pack_mode = get_pack_flags()
|
||||
|
||||
has_device_option = pack_devices and bool(device_int_options)
|
||||
has_traffic_option = pack_traffic and bool(traffic_int_options)
|
||||
|
||||
if has_device_option and current_devices is not None and int(current_devices) == 0:
|
||||
has_device_option = False
|
||||
selected_devices = None
|
||||
|
||||
if has_traffic_option and current_traffic_gb is not None and int(current_traffic_gb) == 0:
|
||||
has_traffic_option = False
|
||||
selected_traffic_gb = None
|
||||
|
||||
if not has_device_option:
|
||||
selected_devices = None
|
||||
if not has_traffic_option:
|
||||
selected_traffic_gb = None
|
||||
|
||||
await state.update_data(
|
||||
addon_selected_device_limit=selected_devices,
|
||||
addon_selected_traffic_gb=selected_traffic_gb,
|
||||
)
|
||||
|
||||
current_devices_for_price = int(current_devices) if current_devices is not None else None
|
||||
current_traffic_for_price = int(current_traffic_gb) if current_traffic_gb is not None else None
|
||||
|
||||
base_price_for_current = calculate_config_price(
|
||||
tariff=tariff,
|
||||
selected_device_limit=current_devices_for_price,
|
||||
selected_traffic_gb=current_traffic_for_price,
|
||||
)
|
||||
|
||||
new_devices_for_price = current_devices_for_price
|
||||
if has_device_option and selected_devices is not None:
|
||||
pack_devices_val = int(selected_devices)
|
||||
if pack_devices_val <= 0 or (new_devices_for_price is not None and new_devices_for_price <= 0):
|
||||
new_devices_for_price = 0
|
||||
else:
|
||||
new_devices_for_price = (new_devices_for_price or 0) + pack_devices_val
|
||||
|
||||
new_traffic_for_price = current_traffic_for_price
|
||||
if has_traffic_option and selected_traffic_gb is not None:
|
||||
pack_traffic_val = int(selected_traffic_gb)
|
||||
if pack_traffic_val <= 0 or (new_traffic_for_price is not None and new_traffic_for_price <= 0):
|
||||
new_traffic_for_price = 0
|
||||
else:
|
||||
new_traffic_for_price = (new_traffic_for_price or 0) + pack_traffic_val
|
||||
|
||||
price_with_pack = calculate_config_price(
|
||||
tariff=tariff,
|
||||
selected_device_limit=new_devices_for_price,
|
||||
selected_traffic_gb=new_traffic_for_price,
|
||||
)
|
||||
extra_price = max(0, price_with_pack - base_price_for_current)
|
||||
|
||||
logger.debug(
|
||||
"[ADDONS] PACK_MODE calculated prices: "
|
||||
f"base_price_for_current={base_price_for_current} price_with_pack={price_with_pack} extra_price={extra_price} "
|
||||
f"has_device_option={has_device_option} has_traffic_option={has_traffic_option} pack_mode={pack_mode!r}"
|
||||
)
|
||||
|
||||
tg_id = callback.from_user.id
|
||||
language_code = getattr(callback.from_user, "language_code", None)
|
||||
|
||||
extra_price_text = await format_for_user(session, tg_id, float(extra_price), language_code)
|
||||
|
||||
current_devices_label = format_devices_label(current_devices)
|
||||
current_traffic_label = format_traffic_label(current_traffic_gb)
|
||||
|
||||
has_device_pack_selected = has_device_option and selected_devices is not None
|
||||
has_traffic_pack_selected = has_traffic_option and selected_traffic_gb is not None
|
||||
|
||||
if has_device_pack_selected:
|
||||
selected_devices_label = format_devices_label(selected_devices)
|
||||
else:
|
||||
selected_devices_label = None
|
||||
|
||||
if has_traffic_pack_selected:
|
||||
selected_traffic_label = format_traffic_label(selected_traffic_gb)
|
||||
else:
|
||||
selected_traffic_label = None
|
||||
|
||||
if has_device_pack_selected:
|
||||
current_devices_value = int(current_devices) if current_devices else 0
|
||||
selected_devices_value = int(selected_devices)
|
||||
if current_devices_value <= 0 or selected_devices_value <= 0:
|
||||
total_devices_value = 0
|
||||
else:
|
||||
total_devices_value = current_devices_value + selected_devices_value
|
||||
total_devices_label = format_devices_label(total_devices_value)
|
||||
else:
|
||||
total_devices_label = None
|
||||
|
||||
if has_traffic_pack_selected:
|
||||
current_traffic_value = int(current_traffic_gb) if current_traffic_gb else 0
|
||||
selected_traffic_value = int(selected_traffic_gb)
|
||||
if current_traffic_value <= 0 or selected_traffic_value <= 0:
|
||||
total_after_gb = 0
|
||||
else:
|
||||
total_after_gb = current_traffic_value + selected_traffic_value
|
||||
total_traffic_label = format_traffic_label(total_after_gb)
|
||||
else:
|
||||
total_traffic_label = None
|
||||
|
||||
text = build_addons_pack_screen_text(
|
||||
tariff_name=tariff_name,
|
||||
current_devices_label=current_devices_label,
|
||||
current_traffic_label=current_traffic_label if current_traffic_gb is not None else None,
|
||||
selected_devices_label=selected_devices_label,
|
||||
selected_traffic_label=selected_traffic_label,
|
||||
total_devices_label=total_devices_label,
|
||||
total_traffic_label=total_traffic_label,
|
||||
extra_price_text=extra_price_text,
|
||||
has_device_option=has_device_option,
|
||||
has_traffic_option=has_traffic_option,
|
||||
)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
device_buttons = []
|
||||
traffic_buttons = []
|
||||
|
||||
if has_device_option:
|
||||
for value in device_int_options:
|
||||
is_selected = selected_devices is not None and int(value) == int(selected_devices)
|
||||
mark = " ✅" if is_selected else ""
|
||||
if value == 0:
|
||||
caption = f"{UNLIMITED_DEVICES_LABEL.capitalize()}{mark}"
|
||||
else:
|
||||
caption = f"{value} устройств{mark}"
|
||||
device_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=caption,
|
||||
callback_data=f"key_addons_devices|{email}|{value}",
|
||||
)
|
||||
)
|
||||
|
||||
if has_traffic_option:
|
||||
for value in traffic_int_options:
|
||||
is_selected = selected_traffic_gb is not None and int(selected_traffic_gb) == int(value)
|
||||
mark = " ✅" if is_selected else ""
|
||||
if value == 0:
|
||||
caption = f"{UNLIMITED_TRAFFIC_LABEL.capitalize()}{mark}"
|
||||
else:
|
||||
caption = f"{value} ГБ{mark}"
|
||||
traffic_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=caption,
|
||||
callback_data=f"key_addons_traffic|{email}|{value}",
|
||||
)
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"[ADDONS] PACK_MODE buttons: "
|
||||
f"devices={[b.text for b in device_buttons]} "
|
||||
f"traffic={[b.text for b in traffic_buttons]}"
|
||||
)
|
||||
|
||||
if device_buttons and traffic_buttons:
|
||||
max_len = max(len(device_buttons), len(traffic_buttons))
|
||||
for i in range(max_len):
|
||||
row = []
|
||||
if i < len(device_buttons):
|
||||
row.append(device_buttons[i])
|
||||
if i < len(traffic_buttons):
|
||||
row.append(traffic_buttons[i])
|
||||
builder.row(*row)
|
||||
elif device_buttons:
|
||||
for button in device_buttons:
|
||||
builder.row(button)
|
||||
elif traffic_buttons:
|
||||
for button in traffic_buttons:
|
||||
builder.row(button)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=CONFIRM_ADDON_BUTTON_TEXT.format(amount=extra_price_text),
|
||||
callback_data="key_addons_confirm",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback.message,
|
||||
text=text,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("key_addons|"))
|
||||
async def start_key_addons(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
email = callback.data.split("|")[1]
|
||||
logger.debug(f"[ADDONS] PACK_MODE start_key_addons: tg_id={callback.from_user.id} email={email}")
|
||||
|
||||
record = await get_key_details(session, email)
|
||||
if not record:
|
||||
logger.warning(f"[ADDONS] PACK_MODE: подписка {email} не найдена")
|
||||
await callback.message.answer("❌ Подписка не найдена.")
|
||||
return
|
||||
|
||||
tariff_id = record.get("tariff_id")
|
||||
if not tariff_id:
|
||||
logger.warning(f"[ADDONS] PACK_MODE: для подписки {email} не назначен тариф")
|
||||
await callback.message.answer("❌ Для этой подписки тариф не назначен, расширение недоступно.")
|
||||
return
|
||||
|
||||
tariff = await get_tariff_by_id(session, int(tariff_id))
|
||||
if not tariff:
|
||||
logger.error(f"[ADDONS] PACK_MODE: тариф {tariff_id} не найден для email={email}")
|
||||
await callback.message.answer("❌ Тариф не найден.")
|
||||
return
|
||||
|
||||
if not tariff.get("configurable"):
|
||||
logger.info(f"[ADDONS] PACK_MODE: тариф {tariff_id} не конфигурируемый, расширение недоступно")
|
||||
await callback.message.answer("❌ Для этого тарифа расширение через конфигуратор недоступно.")
|
||||
return
|
||||
|
||||
cfg = normalize_tariff_config(tariff)
|
||||
|
||||
raw_device_options = cfg.get("device_options") or tariff.get("device_options") or []
|
||||
raw_traffic_options = cfg.get("traffic_options_gb") or tariff.get("traffic_options_gb") or []
|
||||
|
||||
device_int_options: list[int] = []
|
||||
for value in raw_device_options:
|
||||
try:
|
||||
device_int_options.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
traffic_int_options: list[int] = []
|
||||
for value in raw_traffic_options:
|
||||
try:
|
||||
traffic_int_options.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
base_device_limit = cfg.get("base_device_limit")
|
||||
if base_device_limit is None:
|
||||
base_device_limit = tariff.get("device_limit")
|
||||
if base_device_limit is not None:
|
||||
try:
|
||||
base_device_int = int(base_device_limit)
|
||||
if base_device_int not in device_int_options:
|
||||
raw_device_options.append(base_device_int)
|
||||
device_int_options.append(base_device_int)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
device_overrides_cfg = cfg.get("device_price_overrides") or tariff.get("device_overrides") or {}
|
||||
if "0" in device_overrides_cfg and 0 not in device_int_options:
|
||||
raw_device_options.append(0)
|
||||
device_int_options.append(0)
|
||||
|
||||
base_traffic_gb = cfg.get("base_traffic_gb")
|
||||
if base_traffic_gb is None:
|
||||
raw_limit = tariff.get("traffic_limit")
|
||||
if raw_limit:
|
||||
raw_limit = int(raw_limit)
|
||||
if raw_limit >= GB:
|
||||
base_traffic_gb = int(raw_limit / GB)
|
||||
else:
|
||||
base_traffic_gb = raw_limit
|
||||
if base_traffic_gb is not None:
|
||||
try:
|
||||
base_traffic_int = int(base_traffic_gb)
|
||||
if base_traffic_int not in traffic_int_options:
|
||||
raw_traffic_options.append(base_traffic_int)
|
||||
traffic_int_options.append(base_traffic_int)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
traffic_overrides_cfg = cfg.get("traffic_price_overrides") or tariff.get("traffic_overrides") or {}
|
||||
if "0" in traffic_overrides_cfg and 0 not in traffic_int_options:
|
||||
raw_traffic_options.append(0)
|
||||
traffic_int_options.append(0)
|
||||
|
||||
try:
|
||||
device_options = sorted(
|
||||
raw_device_options,
|
||||
key=lambda v: (int(v) == 0, int(v)),
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
device_options = raw_device_options
|
||||
|
||||
try:
|
||||
traffic_options = sorted(
|
||||
raw_traffic_options,
|
||||
key=lambda v: (int(v) == 0, int(v)),
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
traffic_options = raw_traffic_options
|
||||
|
||||
logger.info(
|
||||
"[ADDONS] PACK_MODE start_key_addons options: "
|
||||
f"email={email} tariff_id={tariff_id} "
|
||||
f"device_options={device_options} traffic_options={traffic_options}"
|
||||
)
|
||||
|
||||
selected_device_limit_db = record.get("selected_device_limit")
|
||||
selected_traffic_limit_db = record.get("selected_traffic_limit")
|
||||
original_price_db = record.get("selected_price_rub")
|
||||
current_device_limit_db = record.get("current_device_limit")
|
||||
current_traffic_limit_db = record.get("current_traffic_limit")
|
||||
|
||||
base_devices = tariff.get("device_limit")
|
||||
base_devices = int(base_devices) if base_devices is not None else None
|
||||
|
||||
base_traffic_bytes = tariff.get("traffic_limit")
|
||||
base_traffic_gb = int(base_traffic_bytes / GB) if base_traffic_bytes else None
|
||||
|
||||
current_devices = (
|
||||
int(current_device_limit_db)
|
||||
if current_device_limit_db is not None
|
||||
else (int(selected_device_limit_db) if selected_device_limit_db is not None else base_devices)
|
||||
)
|
||||
current_traffic_gb = (
|
||||
int(current_traffic_limit_db)
|
||||
if current_traffic_limit_db is not None
|
||||
else (int(selected_traffic_limit_db) if selected_traffic_limit_db is not None else base_traffic_gb)
|
||||
)
|
||||
|
||||
pack_devices, pack_traffic, pack_mode = get_pack_flags()
|
||||
|
||||
has_device_pack = (
|
||||
pack_devices and bool(device_options) and not (current_devices is not None and int(current_devices) == 0)
|
||||
)
|
||||
has_traffic_pack = (
|
||||
pack_traffic and bool(traffic_options) and not (current_traffic_gb is not None and int(current_traffic_gb) == 0)
|
||||
)
|
||||
|
||||
has_any_pack = has_device_pack or has_traffic_pack
|
||||
if not has_any_pack:
|
||||
logger.warning(
|
||||
f"[ADDONS] PACK_MODE: пакеты недоступны, уже максимальные параметры "
|
||||
f"email={email} current_devices={current_devices} current_traffic_gb={current_traffic_gb} "
|
||||
f"pack_mode={pack_mode!r}"
|
||||
)
|
||||
await callback.message.answer("❌ Для этой подписки пакеты уже недоступны.")
|
||||
return
|
||||
|
||||
current_devices_for_price = int(current_devices) if current_devices is not None else None
|
||||
current_traffic_gb_for_price = int(current_traffic_gb) if current_traffic_gb is not None else None
|
||||
|
||||
config_price_for_current = calculate_config_price(
|
||||
tariff=tariff,
|
||||
selected_device_limit=current_devices_for_price,
|
||||
selected_traffic_gb=current_traffic_gb_for_price,
|
||||
)
|
||||
|
||||
try:
|
||||
original_price_from_db = int(original_price_db) if original_price_db is not None else 0
|
||||
except (TypeError, ValueError):
|
||||
original_price_from_db = 0
|
||||
|
||||
original_price = 0
|
||||
|
||||
logger.debug(
|
||||
"[ADDONS] PACK_MODE start_key_addons state: "
|
||||
f"email={email} tariff_id={tariff_id} current_devices={current_devices} current_traffic_gb={current_traffic_gb} "
|
||||
f"config_price_for_current={config_price_for_current} original_price_db={original_price_db} "
|
||||
f"original_price={original_price} original_price_from_db={original_price_from_db} "
|
||||
f"pack_mode={pack_mode!r}"
|
||||
)
|
||||
|
||||
cfg_for_state = dict(cfg)
|
||||
if device_options:
|
||||
cfg_for_state["device_options"] = device_options
|
||||
if traffic_options:
|
||||
cfg_for_state["traffic_options_gb"] = traffic_options
|
||||
|
||||
await state.update_data(
|
||||
addon_key_email=email,
|
||||
addon_tariff_id=int(tariff_id),
|
||||
addon_tariff_config=cfg_for_state,
|
||||
addon_current_device_limit=current_devices,
|
||||
addon_current_traffic_gb=current_traffic_gb,
|
||||
addon_original_price=original_price,
|
||||
addon_selected_device_limit=None,
|
||||
addon_selected_traffic_gb=None,
|
||||
)
|
||||
await state.set_state(KeyAddonConfigState.configuring)
|
||||
|
||||
await render_addons_screen(callback, state, session)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("key_addons_devices|"), KeyAddonConfigState.configuring)
|
||||
async def handle_addons_devices_choice(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
parts = callback.data.split("|", 2)
|
||||
if len(parts) < 3:
|
||||
await callback.answer("Некорректные данные", show_alert=True)
|
||||
return
|
||||
|
||||
new_devices = int(parts[2])
|
||||
|
||||
data = await state.get_data()
|
||||
selected_devices = data.get("addon_selected_device_limit")
|
||||
|
||||
logger.debug(
|
||||
"[ADDONS] PACK_MODE handle_addons_devices_choice: "
|
||||
f"tg_id={callback.from_user.id} email={data.get('addon_key_email')} "
|
||||
f"new_devices={new_devices} selected_devices={selected_devices}"
|
||||
)
|
||||
|
||||
if selected_devices is not None and int(selected_devices) == new_devices:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
await state.update_data(addon_selected_device_limit=new_devices)
|
||||
await render_addons_screen(callback, state, session)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("key_addons_traffic|"), KeyAddonConfigState.configuring)
|
||||
async def handle_addons_traffic_choice(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
parts = callback.data.split("|", 2)
|
||||
if len(parts) < 3:
|
||||
await callback.answer("Некорректные данные", show_alert=True)
|
||||
return
|
||||
|
||||
new_traffic_gb = int(parts[2])
|
||||
|
||||
data = await state.get_data()
|
||||
selected_traffic_gb = data.get("addon_selected_traffic_gb")
|
||||
|
||||
logger.debug(
|
||||
"[ADDONS] PACK_MODE handle_addons_traffic_choice: "
|
||||
f"tg_id={callback.from_user.id} email={data.get('addon_key_email')} "
|
||||
f"new_traffic_gb={new_traffic_gb} selected_traffic_gb={selected_traffic_gb}"
|
||||
)
|
||||
|
||||
if selected_traffic_gb is not None and int(selected_traffic_gb) == new_traffic_gb:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
await state.update_data(addon_selected_traffic_gb=new_traffic_gb)
|
||||
await render_addons_screen(callback, state, session)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "key_addons_confirm", KeyAddonConfigState.configuring)
|
||||
async def handle_addons_confirm(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
from handlers.keys.operations import renew_key_in_cluster
|
||||
|
||||
tg_id = callback.from_user.id
|
||||
data = await state.get_data()
|
||||
|
||||
email = data.get("addon_key_email")
|
||||
tariff_id = data.get("addon_tariff_id")
|
||||
original_price = int(data.get("addon_original_price") or 0)
|
||||
selected_devices = data.get("addon_selected_device_limit")
|
||||
selected_traffic_gb = data.get("addon_selected_traffic_gb")
|
||||
current_devices = data.get("addon_current_device_limit")
|
||||
current_traffic_gb = data.get("addon_current_traffic_gb")
|
||||
|
||||
logger.info(
|
||||
"[ADDONS] PACK_MODE handle_addons_confirm: "
|
||||
f"tg_id={tg_id} email={email} tariff_id={tariff_id} original_price={original_price} "
|
||||
f"selected_devices={selected_devices} selected_traffic_gb={selected_traffic_gb} "
|
||||
f"current_devices={current_devices} current_traffic_gb={current_traffic_gb}"
|
||||
)
|
||||
|
||||
if not email or not tariff_id:
|
||||
await callback.message.answer("❌ Данные для изменения подписки не найдены.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
record = await get_key_details(session, email)
|
||||
if not record:
|
||||
logger.warning(f"[ADDONS] PACK_MODE: подписка {email} не найдена в handle_addons_confirm")
|
||||
await callback.message.answer("❌ Подписка не найдена.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
tariff = await get_tariff_by_id(session, int(tariff_id))
|
||||
if not tariff:
|
||||
logger.error(f"[ADDONS] PACK_MODE: тариф {tariff_id} не найден в handle_addons_confirm")
|
||||
await callback.message.answer("❌ Тариф не найден.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
cfg = data.get("addon_tariff_config") or {}
|
||||
device_options = cfg.get("device_options") or []
|
||||
traffic_options = cfg.get("traffic_options_gb") or []
|
||||
|
||||
pack_devices, pack_traffic, pack_mode = get_pack_flags()
|
||||
|
||||
has_device_option = pack_devices and bool(device_options)
|
||||
has_traffic_option = pack_traffic and bool(traffic_options)
|
||||
|
||||
if has_device_option and current_devices is not None and int(current_devices) == 0:
|
||||
has_device_option = False
|
||||
selected_devices = None
|
||||
|
||||
if has_traffic_option and current_traffic_gb is not None and int(current_traffic_gb) == 0:
|
||||
has_traffic_option = False
|
||||
selected_traffic_gb = None
|
||||
|
||||
current_devices_for_price = int(current_devices) if current_devices is not None else None
|
||||
current_traffic_for_price = int(current_traffic_gb) if current_traffic_gb is not None else None
|
||||
|
||||
base_price_for_current = calculate_config_price(
|
||||
tariff=tariff,
|
||||
selected_device_limit=current_devices_for_price,
|
||||
selected_traffic_gb=current_traffic_for_price,
|
||||
)
|
||||
|
||||
new_devices_for_price = current_devices_for_price
|
||||
if has_device_option and selected_devices is not None:
|
||||
pack_devices_val = int(selected_devices)
|
||||
if pack_devices_val <= 0 or (new_devices_for_price is not None and new_devices_for_price <= 0):
|
||||
new_devices_for_price = 0
|
||||
else:
|
||||
new_devices_for_price = (new_devices_for_price or 0) + pack_devices_val
|
||||
|
||||
new_traffic_for_price = current_traffic_for_price
|
||||
if has_traffic_option and selected_traffic_gb is not None:
|
||||
pack_traffic_val = int(selected_traffic_gb)
|
||||
if pack_traffic_val <= 0 or (new_traffic_for_price is not None and new_traffic_for_price <= 0):
|
||||
new_traffic_for_price = 0
|
||||
else:
|
||||
new_traffic_for_price = (new_traffic_for_price or 0) + pack_traffic_val
|
||||
|
||||
price_with_pack = calculate_config_price(
|
||||
tariff=tariff,
|
||||
selected_device_limit=new_devices_for_price,
|
||||
selected_traffic_gb=new_traffic_for_price,
|
||||
)
|
||||
extra_price = max(0, price_with_pack - base_price_for_current)
|
||||
|
||||
logger.debug(
|
||||
"[ADDONS] PACK_MODE confirm prices: "
|
||||
f"base_price_for_current={base_price_for_current} price_with_pack={price_with_pack} "
|
||||
f"original_price={original_price} extra_price={extra_price} "
|
||||
f"has_device_option={has_device_option} has_traffic_option={has_traffic_option} "
|
||||
f"pack_mode={pack_mode!r}"
|
||||
)
|
||||
|
||||
if extra_price <= 0:
|
||||
logger.info(f"[ADDONS] PACK_MODE: extra_price <= 0, доплата не требуется, email={email}")
|
||||
await state.clear()
|
||||
await render_key_info(callback.message, session, email, "img/pic_view.jpg")
|
||||
await callback.answer(ADDONS_NO_EXTRA_PAYMENT_TEXT, show_alert=True)
|
||||
return
|
||||
|
||||
balance = await get_balance(session, tg_id)
|
||||
logger.debug(f"[ADDONS] PACK_MODE balance check: tg_id={tg_id} balance={balance} extra_price={extra_price}")
|
||||
|
||||
if balance < extra_price:
|
||||
required_amount = ceil(extra_price - balance)
|
||||
language_code = getattr(callback.from_user, "language_code", None)
|
||||
required_amount_text = await format_for_user(session, tg_id, float(required_amount), language_code)
|
||||
|
||||
logger.info(
|
||||
"[ADDONS] PACK_MODE: недостаточно средств "
|
||||
f"balance={balance} extra_price={extra_price} required_amount={required_amount} "
|
||||
f"tg_id={tg_id} USE_NEW_PAYMENT_FLOW={USE_NEW_PAYMENT_FLOW}"
|
||||
)
|
||||
|
||||
if USE_NEW_PAYMENT_FLOW:
|
||||
handled = await try_fast_payment_flow(
|
||||
callback,
|
||||
session,
|
||||
state,
|
||||
tg_id=tg_id,
|
||||
temp_key="waiting_for_addons_payment",
|
||||
temp_payload={
|
||||
"email": email,
|
||||
"tariff_id": int(tariff_id),
|
||||
"original_price": original_price,
|
||||
"selected_device_limit": selected_devices,
|
||||
"selected_traffic_gb": selected_traffic_gb,
|
||||
"current_device_limit": current_devices,
|
||||
"current_traffic_gb": current_traffic_gb,
|
||||
"required_amount": required_amount,
|
||||
},
|
||||
required_amount=required_amount,
|
||||
)
|
||||
logger.debug(f"[ADDONS] PACK_MODE try_fast_payment_flow handled={handled} tg_id={tg_id} email={email}")
|
||||
if handled:
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
|
||||
await edit_or_send_message(
|
||||
target_message=callback.message,
|
||||
text=INSUFFICIENT_FUNDS_RENEWAL_MSG.format(required_amount=required_amount_text),
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
expiry_time = record["expiry_time"]
|
||||
client_id = record["client_id"]
|
||||
server_id = record["server_id"]
|
||||
|
||||
device_limit_effective_current, traffic_limit_bytes_effective_current = await get_effective_limits_for_key(
|
||||
session=session,
|
||||
tariff_id=int(tariff_id),
|
||||
selected_device_limit=int(current_devices) if current_devices is not None else None,
|
||||
selected_traffic_gb=int(current_traffic_gb) if current_traffic_gb is not None else None,
|
||||
)
|
||||
traffic_limit_gb_effective_current = (
|
||||
int(traffic_limit_bytes_effective_current / GB) if traffic_limit_bytes_effective_current else 0
|
||||
)
|
||||
|
||||
new_device_limit_effective = device_limit_effective_current
|
||||
new_traffic_limit_gb_effective = traffic_limit_gb_effective_current
|
||||
|
||||
if has_device_option and selected_devices is not None:
|
||||
pack_devices_val = int(selected_devices)
|
||||
if pack_devices_val <= 0 or (new_device_limit_effective is not None and new_device_limit_effective <= 0):
|
||||
new_device_limit_effective = 0
|
||||
else:
|
||||
if new_device_limit_effective is None:
|
||||
new_device_limit_effective = pack_devices_val
|
||||
else:
|
||||
new_device_limit_effective = new_device_limit_effective + pack_devices_val
|
||||
|
||||
if has_traffic_option and selected_traffic_gb is not None:
|
||||
pack_traffic_val = int(selected_traffic_gb)
|
||||
if pack_traffic_val <= 0 or new_traffic_limit_gb_effective <= 0:
|
||||
new_traffic_limit_gb_effective = 0
|
||||
else:
|
||||
new_traffic_limit_gb_effective = new_traffic_limit_gb_effective + pack_traffic_val
|
||||
|
||||
current_subgroup = None
|
||||
try:
|
||||
current_tariff_id = record.get("tariff_id")
|
||||
if current_tariff_id:
|
||||
current_tariff = await get_tariff_by_id(session, int(current_tariff_id))
|
||||
if current_tariff:
|
||||
current_subgroup = current_tariff.get("subgroup_title")
|
||||
except Exception as error:
|
||||
logger.warning(f"[ADDONS] PACK_MODE: не удалось определить текущую подгруппу: {error}")
|
||||
|
||||
target_subgroup = tariff.get("subgroup_title")
|
||||
old_subgroup = current_subgroup
|
||||
|
||||
total_gb = new_traffic_limit_gb_effective
|
||||
hwid_device_limit_to_set = new_device_limit_effective
|
||||
|
||||
logger.debug(
|
||||
"[ADDONS] PACK_MODE renew_key_in_cluster params: "
|
||||
f"server_id={server_id} email={email} client_id={client_id} total_gb={total_gb} "
|
||||
f"hwid_device_limit_to_set={hwid_device_limit_to_set} target_subgroup={target_subgroup} "
|
||||
f"old_subgroup={old_subgroup}"
|
||||
)
|
||||
|
||||
await renew_key_in_cluster(
|
||||
cluster_id=server_id,
|
||||
email=email,
|
||||
client_id=client_id,
|
||||
new_expiry_time=expiry_time,
|
||||
total_gb=total_gb,
|
||||
session=session,
|
||||
hwid_device_limit=hwid_device_limit_to_set,
|
||||
reset_traffic=False,
|
||||
target_subgroup=target_subgroup,
|
||||
old_subgroup=old_subgroup,
|
||||
plan=int(tariff_id),
|
||||
)
|
||||
|
||||
await update_balance(session, tg_id, -extra_price)
|
||||
|
||||
await save_key_config_with_mode(
|
||||
session=session,
|
||||
email=email,
|
||||
selected_devices=new_device_limit_effective,
|
||||
selected_traffic_gb=new_traffic_limit_gb_effective,
|
||||
total_price=int(price_with_pack),
|
||||
has_device_choice=has_device_option,
|
||||
has_traffic_choice=has_traffic_option,
|
||||
config_mode="pack",
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
"[ADDONS] PACK_MODE успешная покупка пакета: "
|
||||
f"tg_id={tg_id} email={email} extra_price={extra_price} "
|
||||
f"new_device_limit_effective={new_device_limit_effective} "
|
||||
f"new_traffic_limit_gb_effective={new_traffic_limit_gb_effective} "
|
||||
f"pack_mode={pack_mode!r}"
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
await render_key_info(callback.message, session, email, "img/pic_view.jpg")
|
||||
await callback.answer(ADDONS_PACK_SUCCESS_TEXT, show_alert=True)
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"[ADDONS] PACK_MODE ошибка при покупке пакета для {email}: {error}")
|
||||
await callback.message.answer("❌ Ошибка при обновлении подписки. Попробуйте позже.")
|
||||
await state.clear()
|
||||
@@ -0,0 +1,166 @@
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
from handlers.texts import (
|
||||
ADDONS_CURRENT_HEADER_TEXT,
|
||||
ADDONS_HINT_BOTH_OPTIONS_TEXT,
|
||||
ADDONS_HINT_SINGLE_OPTION_TEXT,
|
||||
ADDONS_NEW_CHOICE_BOTH_TEMPLATE,
|
||||
ADDONS_NEW_CHOICE_DEVICES_TEMPLATE,
|
||||
ADDONS_NEW_CHOICE_TRAFFIC_TEMPLATE,
|
||||
ADDONS_PACK_CURRENT_DEVICES_TEMPLATE,
|
||||
ADDONS_PACK_CURRENT_TRAFFIC_TEMPLATE,
|
||||
ADDONS_PACK_EXTRA_PRICE_TEMPLATE,
|
||||
ADDONS_PACK_HINT_BOTH,
|
||||
ADDONS_PACK_HINT_DEVICES,
|
||||
ADDONS_PACK_HINT_TRAFFIC,
|
||||
ADDONS_PACK_SELECTED_DEVICES_TEMPLATE,
|
||||
ADDONS_PACK_SELECTED_TRAFFIC_TEMPLATE,
|
||||
ADDONS_PACK_TITLE_TEMPLATE,
|
||||
ADDONS_PACK_TOTAL_DEVICES_TEMPLATE,
|
||||
ADDONS_PACK_TOTAL_TRAFFIC_TEMPLATE,
|
||||
ADDONS_PRICE_EXTRA_TEMPLATE,
|
||||
ADDONS_PRICE_TOTAL_TEMPLATE,
|
||||
ADDONS_TITLE_TEMPLATE,
|
||||
UNLIMITED_DEVICES_LABEL,
|
||||
UNLIMITED_TRAFFIC_LABEL,
|
||||
)
|
||||
|
||||
|
||||
class KeyAddonConfigState(StatesGroup):
|
||||
configuring = State()
|
||||
|
||||
|
||||
def format_devices_label(value, default_text: str = "по умолчанию") -> str:
|
||||
if value is None:
|
||||
return default_text
|
||||
value_int = int(value)
|
||||
if value_int <= 0:
|
||||
return UNLIMITED_DEVICES_LABEL
|
||||
return f"{value_int} устройств"
|
||||
|
||||
|
||||
def format_traffic_label(value, default_text: str = "по умолчанию") -> str:
|
||||
if value is None:
|
||||
return default_text
|
||||
value_int = int(value)
|
||||
if value_int <= 0:
|
||||
return UNLIMITED_TRAFFIC_LABEL
|
||||
return f"{value_int} ГБ"
|
||||
|
||||
|
||||
def is_not_downgrade(current_value, new_value) -> bool:
|
||||
if current_value is None:
|
||||
return True
|
||||
current_int = int(current_value)
|
||||
new_int = int(new_value)
|
||||
current_cmp = current_int if current_int > 0 else 10**9
|
||||
new_cmp = new_int if new_int > 0 else 10**9
|
||||
return new_cmp >= current_cmp
|
||||
|
||||
|
||||
def build_addons_screen_text(
|
||||
*,
|
||||
tariff_name: str,
|
||||
current_devices_label: str,
|
||||
current_traffic_label: str,
|
||||
new_devices_label: str,
|
||||
new_traffic_label: str,
|
||||
has_device_choice: bool,
|
||||
has_traffic_choice: bool,
|
||||
total_price_text: str,
|
||||
extra_price_text: str,
|
||||
downgrade_warning: str | None = None,
|
||||
) -> str:
|
||||
current_text = f"{current_devices_label}, {current_traffic_label}"
|
||||
|
||||
if has_device_choice and has_traffic_choice:
|
||||
new_choice_line = ADDONS_NEW_CHOICE_BOTH_TEMPLATE.format(
|
||||
new_devices_label=new_devices_label,
|
||||
new_traffic_label=new_traffic_label,
|
||||
)
|
||||
hint_line = ADDONS_HINT_BOTH_OPTIONS_TEXT
|
||||
elif has_device_choice:
|
||||
new_choice_line = ADDONS_NEW_CHOICE_DEVICES_TEMPLATE.format(
|
||||
new_devices_label=new_devices_label,
|
||||
)
|
||||
hint_line = ADDONS_HINT_SINGLE_OPTION_TEXT
|
||||
elif has_traffic_choice:
|
||||
new_choice_line = ADDONS_NEW_CHOICE_TRAFFIC_TEMPLATE.format(
|
||||
new_traffic_label=new_traffic_label,
|
||||
)
|
||||
hint_line = ADDONS_HINT_SINGLE_OPTION_TEXT
|
||||
else:
|
||||
new_choice_line = ""
|
||||
hint_line = ADDONS_HINT_SINGLE_OPTION_TEXT
|
||||
|
||||
text = (
|
||||
ADDONS_TITLE_TEMPLATE.format(tariff_name=tariff_name)
|
||||
+ "\n\n"
|
||||
+ ADDONS_CURRENT_HEADER_TEXT
|
||||
+ "\n"
|
||||
+ f"<blockquote>{current_text}</blockquote>\n"
|
||||
+ new_choice_line
|
||||
+ ADDONS_PRICE_TOTAL_TEMPLATE.format(total_price_text=total_price_text)
|
||||
+ "\n"
|
||||
+ ADDONS_PRICE_EXTRA_TEMPLATE.format(extra_price_text=extra_price_text)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
if downgrade_warning:
|
||||
text += f"\n{downgrade_warning}\n"
|
||||
|
||||
text += f"\n{hint_line}"
|
||||
return text
|
||||
|
||||
|
||||
def build_addons_pack_screen_text(
|
||||
*,
|
||||
tariff_name: str,
|
||||
current_devices_label: str,
|
||||
current_traffic_label: str | None,
|
||||
selected_devices_label: str | None,
|
||||
selected_traffic_label: str | None,
|
||||
total_devices_label: str | None,
|
||||
total_traffic_label: str | None,
|
||||
extra_price_text: str,
|
||||
has_device_option: bool,
|
||||
has_traffic_option: bool,
|
||||
) -> str:
|
||||
params_lines: list[str] = []
|
||||
|
||||
params_lines.append(ADDONS_PACK_CURRENT_DEVICES_TEMPLATE.format(value=current_devices_label))
|
||||
|
||||
if current_traffic_label is not None:
|
||||
params_lines.append(ADDONS_PACK_CURRENT_TRAFFIC_TEMPLATE.format(value=current_traffic_label))
|
||||
|
||||
if has_device_option and selected_devices_label is not None:
|
||||
params_lines.append(ADDONS_PACK_SELECTED_DEVICES_TEMPLATE.format(value=selected_devices_label))
|
||||
|
||||
if has_traffic_option and selected_traffic_label is not None:
|
||||
params_lines.append(ADDONS_PACK_SELECTED_TRAFFIC_TEMPLATE.format(value=selected_traffic_label))
|
||||
|
||||
if has_device_option and total_devices_label is not None:
|
||||
params_lines.append(ADDONS_PACK_TOTAL_DEVICES_TEMPLATE.format(value=total_devices_label))
|
||||
|
||||
if has_traffic_option and total_traffic_label is not None:
|
||||
params_lines.append(ADDONS_PACK_TOTAL_TRAFFIC_TEMPLATE.format(value=total_traffic_label))
|
||||
|
||||
params_block = "<blockquote>" + "\n".join(params_lines) + "</blockquote>"
|
||||
|
||||
text_parts: list[str] = []
|
||||
text_parts.append(ADDONS_PACK_TITLE_TEMPLATE.format(tariff_name=tariff_name))
|
||||
text_parts.append("")
|
||||
text_parts.append(params_block)
|
||||
text_parts.append("")
|
||||
text_parts.append(ADDONS_PACK_EXTRA_PRICE_TEMPLATE.format(value=extra_price_text))
|
||||
|
||||
if has_device_option or has_traffic_option:
|
||||
text_parts.append("")
|
||||
if has_device_option and has_traffic_option:
|
||||
text_parts.append(ADDONS_PACK_HINT_BOTH)
|
||||
elif has_traffic_option:
|
||||
text_parts.append(ADDONS_PACK_HINT_TRAFFIC)
|
||||
else:
|
||||
text_parts.append(ADDONS_PACK_HINT_DEVICES)
|
||||
|
||||
return "\n".join(text_parts)
|
||||
@@ -0,0 +1,936 @@
|
||||
from datetime import datetime, timedelta
|
||||
from math import ceil
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import USE_NEW_PAYMENT_FLOW
|
||||
from core.settings.tariffs_config import normalize_tariff_config
|
||||
from database import get_balance, get_tariff_by_id
|
||||
from database.notifications import check_hot_lead_discount
|
||||
from handlers.buttons import CONFIG_PAY_BUTTON_TEXT, MAIN_MENU, PAYMENT
|
||||
from handlers.payments.currency_rates import format_for_user
|
||||
from handlers.payments.fast_payment_flow import try_fast_payment_flow
|
||||
from handlers.tariffs.tariff_display import GB
|
||||
from handlers.texts import (
|
||||
CONFIG_SCREEN_TEMPLATE,
|
||||
CREATING_CONNECTION_MSG,
|
||||
DEFAULT_LIMIT_LABEL,
|
||||
INSUFFICIENT_FUNDS_MSG,
|
||||
UNLIMITED_DEVICES_LABEL,
|
||||
UNLIMITED_TRAFFIC_LABEL,
|
||||
)
|
||||
from handlers.utils import edit_or_send_message
|
||||
from hooks.processors import process_check_discount_validity
|
||||
from logger import logger
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
CREATING_KEY_BUTTON_TEXT = "⏳ Подождите..."
|
||||
|
||||
|
||||
class TariffUserConfigState(StatesGroup):
|
||||
"""Состояния конфигуратора тарифа для пользователя."""
|
||||
|
||||
configuring = State()
|
||||
|
||||
|
||||
def calculate_config_price(
|
||||
tariff: dict,
|
||||
selected_device_limit: int | None = None,
|
||||
selected_traffic_gb: int | None = None,
|
||||
) -> int:
|
||||
"""Рассчитывает цену тарифа с учётом выбранных лимитов."""
|
||||
cfg = normalize_tariff_config(tariff)
|
||||
|
||||
base_price = int(tariff.get("price_rub") or 0)
|
||||
|
||||
raw_device_options = cfg.get("device_options") or tariff.get("device_options") or []
|
||||
raw_traffic_options = cfg.get("traffic_options_gb") or tariff.get("traffic_options_gb") or []
|
||||
|
||||
device_values: list[int] = []
|
||||
for value in raw_device_options:
|
||||
try:
|
||||
device_values.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
traffic_values: list[int] = []
|
||||
for value in raw_traffic_options:
|
||||
try:
|
||||
traffic_values.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
positive_device_values = [v for v in device_values if v > 0]
|
||||
positive_traffic_values = [v for v in traffic_values if v > 0]
|
||||
|
||||
base_device_limit = cfg.get("base_device_limit")
|
||||
if base_device_limit is None:
|
||||
base_device_limit = tariff.get("device_limit")
|
||||
if base_device_limit is None:
|
||||
if positive_device_values:
|
||||
base_device_limit = min(positive_device_values)
|
||||
elif device_values:
|
||||
base_device_limit = device_values[0]
|
||||
base_device_limit = int(base_device_limit) if base_device_limit is not None else None
|
||||
|
||||
base_traffic_gb = cfg.get("base_traffic_gb")
|
||||
if base_traffic_gb is None:
|
||||
traffic_limit_raw = tariff.get("traffic_limit")
|
||||
if traffic_limit_raw:
|
||||
traffic_limit_raw = int(traffic_limit_raw)
|
||||
if traffic_limit_raw >= GB:
|
||||
base_traffic_gb = int(traffic_limit_raw / GB)
|
||||
else:
|
||||
base_traffic_gb = traffic_limit_raw
|
||||
else:
|
||||
if positive_traffic_values:
|
||||
base_traffic_gb = min(positive_traffic_values)
|
||||
elif traffic_values:
|
||||
base_traffic_gb = traffic_values[0]
|
||||
base_traffic_gb = int(base_traffic_gb) if base_traffic_gb is not None else None
|
||||
|
||||
device_overrides = cfg.get("device_price_overrides") or tariff.get("device_overrides") or {}
|
||||
traffic_overrides = cfg.get("traffic_price_overrides") or tariff.get("traffic_overrides") or {}
|
||||
|
||||
extra_device_step_price = int(cfg.get("extra_device_base_price_rub") or tariff.get("device_step_rub") or 0)
|
||||
extra_traffic_step_price = int(
|
||||
cfg.get("extra_traffic_base_price_per_gb_rub") or tariff.get("traffic_step_rub") or 0
|
||||
)
|
||||
|
||||
devices_extra_price = 0
|
||||
traffic_extra_price = 0
|
||||
|
||||
if selected_device_limit is not None and base_device_limit is not None:
|
||||
selected_device_limit = int(selected_device_limit)
|
||||
override_key = str(selected_device_limit)
|
||||
|
||||
if override_key in device_overrides:
|
||||
devices_extra_price = int(device_overrides[override_key])
|
||||
else:
|
||||
if selected_device_limit <= 0:
|
||||
if positive_device_values:
|
||||
effective_devices = max(positive_device_values)
|
||||
extra_devices = max(0, effective_devices - base_device_limit)
|
||||
devices_extra_price = extra_devices * extra_device_step_price
|
||||
else:
|
||||
extra_devices = max(0, selected_device_limit - base_device_limit)
|
||||
devices_extra_price = extra_devices * extra_device_step_price
|
||||
|
||||
if selected_traffic_gb is not None and base_traffic_gb is not None:
|
||||
selected_traffic_gb = int(selected_traffic_gb)
|
||||
override_key = str(selected_traffic_gb)
|
||||
|
||||
if override_key in traffic_overrides:
|
||||
traffic_extra_price = int(traffic_overrides[override_key])
|
||||
else:
|
||||
if selected_traffic_gb <= 0:
|
||||
if positive_traffic_values:
|
||||
effective_gb = max(positive_traffic_values)
|
||||
extra_traffic = max(0, effective_gb - base_traffic_gb)
|
||||
traffic_extra_price = extra_traffic * extra_traffic_step_price
|
||||
else:
|
||||
extra_traffic = max(0, selected_traffic_gb - base_traffic_gb)
|
||||
traffic_extra_price = extra_traffic * extra_traffic_step_price
|
||||
|
||||
total_price = int(base_price + devices_extra_price + traffic_extra_price)
|
||||
return total_price
|
||||
|
||||
|
||||
async def start_tariff_config(
|
||||
callback_query: CallbackQuery,
|
||||
state: FSMContext,
|
||||
session: Any,
|
||||
tariff_id: int,
|
||||
):
|
||||
"""Запускает конфигуратор тарифа по id."""
|
||||
tariff = await get_tariff_by_id(session, int(tariff_id))
|
||||
if not tariff:
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="❌ Указанный тариф не найден.",
|
||||
)
|
||||
await callback_query.answer()
|
||||
logger.warning(f"[TARIFF_CFG] start_tariff_config tariff_not_found: tariff_id={tariff_id}")
|
||||
return
|
||||
|
||||
if not tariff.get("configurable"):
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="❌ Этот тариф нельзя настроить.",
|
||||
)
|
||||
await callback_query.answer()
|
||||
logger.info(f"[TARIFF_CFG] start_tariff_config not_configurable: tariff_id={tariff_id}")
|
||||
return
|
||||
|
||||
await start_user_tariff_configurator(
|
||||
callback_query=callback_query,
|
||||
session=session,
|
||||
state=state,
|
||||
tariff=tariff,
|
||||
)
|
||||
|
||||
|
||||
async def proceed_purchase_with_values(
|
||||
callback_query: CallbackQuery,
|
||||
session: Any,
|
||||
state: FSMContext,
|
||||
tariff: dict,
|
||||
duration_days: int,
|
||||
price_rub: int,
|
||||
selected_device_limit: int | None = None,
|
||||
selected_traffic_gb: int | None = None,
|
||||
):
|
||||
"""Проверяет баланс и создаёт ключ по выбранной конфигурации."""
|
||||
from ...keys.key_create import create_key, moscow_tz
|
||||
|
||||
tg_id = callback_query.from_user.id
|
||||
|
||||
logger.info(
|
||||
"[TARIFF_CFG] proceed_purchase_with_values: "
|
||||
f"tg_id={tg_id} tariff_id={tariff.get('id')} duration_days={duration_days} "
|
||||
f"price_rub={price_rub} selected_device_limit={selected_device_limit} "
|
||||
f"selected_traffic_gb={selected_traffic_gb}"
|
||||
)
|
||||
|
||||
balance = await get_balance(session, tg_id)
|
||||
|
||||
if balance < price_rub:
|
||||
required_amount = ceil(price_rub - balance)
|
||||
|
||||
logger.info(
|
||||
f"[TARIFF_CFG] insufficient_balance: tg_id={tg_id} balance={balance} required_amount={required_amount}"
|
||||
)
|
||||
|
||||
if USE_NEW_PAYMENT_FLOW:
|
||||
handled = await try_fast_payment_flow(
|
||||
callback_query,
|
||||
session,
|
||||
state,
|
||||
tg_id=tg_id,
|
||||
temp_key="waiting_for_payment",
|
||||
temp_payload={
|
||||
"tariff_id": tariff["id"],
|
||||
"selected_price_rub": price_rub,
|
||||
"selected_duration_days": duration_days,
|
||||
"selected_device_limit": selected_device_limit,
|
||||
"selected_traffic_limit_gb": selected_traffic_gb,
|
||||
"required_amount": required_amount,
|
||||
},
|
||||
required_amount=required_amount,
|
||||
)
|
||||
if handled:
|
||||
return
|
||||
|
||||
language_code = getattr(callback_query.from_user, "language_code", None)
|
||||
required_amount_text = await format_for_user(session, tg_id, float(required_amount), language_code)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=INSUFFICIENT_FUNDS_MSG.format(required_amount=required_amount_text),
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=CREATING_KEY_BUTTON_TEXT, callback_data="creating_key"))
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=CREATING_CONNECTION_MSG,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await callback_query.answer()
|
||||
|
||||
expiry_time = datetime.now(moscow_tz) + timedelta(days=duration_days)
|
||||
|
||||
data_to_update: dict[str, Any] = {"tariff_id": tariff["id"], "selected_price_rub": price_rub}
|
||||
if selected_device_limit is not None:
|
||||
data_to_update["config_selected_device_limit"] = selected_device_limit
|
||||
if selected_traffic_gb is not None:
|
||||
data_to_update["config_selected_traffic_gb"] = selected_traffic_gb
|
||||
|
||||
await state.update_data(**data_to_update)
|
||||
|
||||
logger.info(f"[TARIFF_CFG] create_key: tg_id={tg_id} tariff_id={tariff.get('id')} expiry_time={expiry_time}")
|
||||
|
||||
await create_key(
|
||||
tg_id=tg_id,
|
||||
expiry_time=expiry_time,
|
||||
state=state,
|
||||
session=session,
|
||||
message_or_query=callback_query,
|
||||
plan=tariff["id"],
|
||||
selected_duration_days=duration_days,
|
||||
selected_device_limit=selected_device_limit,
|
||||
selected_traffic_gb=selected_traffic_gb,
|
||||
selected_price_rub=price_rub,
|
||||
)
|
||||
|
||||
|
||||
async def render_user_config_screen(
|
||||
callback_query: CallbackQuery,
|
||||
state: FSMContext,
|
||||
session: Any,
|
||||
):
|
||||
"""Рендерит экран конфигурации тарифа для пользователя."""
|
||||
data = await state.get_data()
|
||||
tariff_id = data.get("config_tariff_id")
|
||||
cfg = data.get("tariff_config") or {}
|
||||
|
||||
tariff = await get_tariff_by_id(session, int(tariff_id))
|
||||
if not tariff:
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="❌ Тариф не найден.",
|
||||
reply_markup=None,
|
||||
)
|
||||
await state.clear()
|
||||
logger.warning(f"[TARIFF_CFG] render_user_config_screen tariff_not_found: tariff_id={tariff_id}")
|
||||
return
|
||||
|
||||
raw_device_options = cfg.get("device_options") or tariff.get("device_options") or []
|
||||
try:
|
||||
device_options = sorted(
|
||||
raw_device_options,
|
||||
key=lambda v: (int(v) == 0, int(v)),
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
device_options = raw_device_options
|
||||
|
||||
raw_traffic_options = cfg.get("traffic_options_gb") or tariff.get("traffic_options_gb") or []
|
||||
try:
|
||||
traffic_options = sorted(
|
||||
raw_traffic_options,
|
||||
key=lambda v: (int(v) == 0, int(v)),
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
traffic_options = raw_traffic_options
|
||||
|
||||
device_int_options: list[int] = []
|
||||
for value in device_options:
|
||||
try:
|
||||
device_int_options.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
traffic_int_options: list[int] = []
|
||||
for value in traffic_options:
|
||||
try:
|
||||
traffic_int_options.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
has_device_option = bool(device_int_options)
|
||||
has_traffic_option = bool(traffic_int_options)
|
||||
has_device_choice = len(device_int_options) > 1
|
||||
has_traffic_choice = len(traffic_int_options) > 1
|
||||
|
||||
selected_devices = data.get("config_selected_device_limit")
|
||||
selected_traffic_gb = data.get("config_selected_traffic_gb")
|
||||
|
||||
base_device_limit = cfg.get("base_device_limit")
|
||||
if base_device_limit is None:
|
||||
base_device_limit = tariff.get("device_limit")
|
||||
if base_device_limit is None:
|
||||
positives = [v for v in device_int_options if v > 0]
|
||||
if positives:
|
||||
base_device_limit = min(positives)
|
||||
elif device_int_options:
|
||||
base_device_limit = device_int_options[0]
|
||||
base_device = int(base_device_limit) if base_device_limit is not None else None
|
||||
|
||||
base_traffic_gb = None
|
||||
raw_base_traffic = tariff.get("traffic_limit")
|
||||
if raw_base_traffic:
|
||||
raw_base_traffic = int(raw_base_traffic)
|
||||
if raw_base_traffic >= GB:
|
||||
base_traffic_gb = int(raw_base_traffic / GB)
|
||||
else:
|
||||
base_traffic_gb = raw_base_traffic
|
||||
if base_traffic_gb is None:
|
||||
cfg_base_traffic = cfg.get("base_traffic_gb")
|
||||
if cfg_base_traffic is not None and int(cfg_base_traffic) > 0:
|
||||
base_traffic_gb = int(cfg_base_traffic)
|
||||
if base_traffic_gb is None:
|
||||
positives = [v for v in traffic_int_options if v > 0]
|
||||
if positives:
|
||||
base_traffic_gb = min(positives)
|
||||
elif traffic_int_options:
|
||||
base_traffic_gb = traffic_int_options[0]
|
||||
base_traffic_gb = int(base_traffic_gb) if base_traffic_gb is not None else None
|
||||
|
||||
if has_device_option:
|
||||
if selected_devices is None:
|
||||
if base_device is not None and base_device in device_int_options:
|
||||
selected_devices = base_device
|
||||
elif device_int_options:
|
||||
selected_devices = device_int_options[0]
|
||||
else:
|
||||
selected_devices = None
|
||||
|
||||
if has_traffic_option:
|
||||
if selected_traffic_gb is None:
|
||||
if base_traffic_gb is not None and base_traffic_gb in traffic_int_options:
|
||||
selected_traffic_gb = base_traffic_gb
|
||||
elif traffic_int_options:
|
||||
selected_traffic_gb = traffic_int_options[0]
|
||||
else:
|
||||
selected_traffic_gb = None
|
||||
|
||||
final_price = calculate_config_price(
|
||||
tariff=tariff,
|
||||
selected_device_limit=int(selected_devices) if selected_devices is not None and has_device_option else None,
|
||||
selected_traffic_gb=int(selected_traffic_gb)
|
||||
if selected_traffic_gb is not None and has_traffic_option
|
||||
else None,
|
||||
)
|
||||
|
||||
tg_id = callback_query.from_user.id
|
||||
language_code = getattr(callback_query.from_user, "language_code", None)
|
||||
price_text = await format_for_user(session, tg_id, float(final_price), language_code)
|
||||
|
||||
base_parts = []
|
||||
if base_device is not None:
|
||||
if int(base_device) <= 0:
|
||||
base_devices_label = UNLIMITED_DEVICES_LABEL
|
||||
else:
|
||||
base_devices_label = f"{int(base_device)} устройств"
|
||||
base_parts.append(base_devices_label)
|
||||
|
||||
if base_traffic_gb is not None:
|
||||
if int(base_traffic_gb) <= 0:
|
||||
base_traffic_label = UNLIMITED_TRAFFIC_LABEL
|
||||
else:
|
||||
base_traffic_label = f"{int(base_traffic_gb)} ГБ"
|
||||
base_parts.append(base_traffic_label)
|
||||
|
||||
if not base_parts:
|
||||
base_text = DEFAULT_LIMIT_LABEL
|
||||
else:
|
||||
base_text = ", ".join(base_parts)
|
||||
|
||||
choice_parts = []
|
||||
|
||||
if has_device_choice:
|
||||
if selected_devices is None:
|
||||
devices_label = DEFAULT_LIMIT_LABEL
|
||||
else:
|
||||
if int(selected_devices) <= 0:
|
||||
devices_label = UNLIMITED_DEVICES_LABEL
|
||||
else:
|
||||
devices_label = f"{int(selected_devices)} устройств"
|
||||
choice_parts.append(devices_label)
|
||||
|
||||
if has_traffic_choice:
|
||||
if selected_traffic_gb is None:
|
||||
traffic_label = DEFAULT_LIMIT_LABEL
|
||||
else:
|
||||
if selected_traffic_gb <= 0:
|
||||
traffic_label = UNLIMITED_TRAFFIC_LABEL
|
||||
else:
|
||||
traffic_label = f"{int(selected_traffic_gb)} ГБ"
|
||||
choice_parts.append(traffic_label)
|
||||
|
||||
if not choice_parts:
|
||||
choice_text = DEFAULT_LIMIT_LABEL
|
||||
else:
|
||||
choice_text = ", ".join(choice_parts)
|
||||
|
||||
text = CONFIG_SCREEN_TEMPLATE.format(
|
||||
base=base_text,
|
||||
choice=choice_text,
|
||||
price=price_text,
|
||||
)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
device_buttons: list[InlineKeyboardButton] = []
|
||||
traffic_buttons: list[InlineKeyboardButton] = []
|
||||
|
||||
if has_device_choice:
|
||||
selected_devices_int = int(selected_devices or 0)
|
||||
for value in device_int_options:
|
||||
mark = " ✅" if value == selected_devices_int else ""
|
||||
if value == 0:
|
||||
caption = f"{UNLIMITED_DEVICES_LABEL.capitalize()}{mark}"
|
||||
else:
|
||||
caption = f"{value} устройств{mark}"
|
||||
device_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=caption,
|
||||
callback_data=f"cfg_user_devices|{tariff_id}|{value}",
|
||||
)
|
||||
)
|
||||
|
||||
if has_traffic_choice:
|
||||
selected_traffic_int = int(selected_traffic_gb or 0)
|
||||
for value in traffic_int_options:
|
||||
mark = " ✅" if value == selected_traffic_int else ""
|
||||
if value == 0:
|
||||
caption = f"{UNLIMITED_TRAFFIC_LABEL.capitalize()}{mark}"
|
||||
else:
|
||||
caption = f"{value} ГБ{mark}"
|
||||
traffic_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=caption,
|
||||
callback_data=f"cfg_user_traffic|{tariff_id}|{value}",
|
||||
)
|
||||
)
|
||||
|
||||
if device_buttons and traffic_buttons:
|
||||
max_len = max(len(device_buttons), len(traffic_buttons))
|
||||
for i in range(max_len):
|
||||
row = []
|
||||
if i < len(device_buttons):
|
||||
row.append(device_buttons[i])
|
||||
if i < len(traffic_buttons):
|
||||
row.append(traffic_buttons[i])
|
||||
builder.row(*row)
|
||||
elif device_buttons:
|
||||
for b in device_buttons:
|
||||
builder.row(b)
|
||||
elif traffic_buttons:
|
||||
for b in traffic_buttons:
|
||||
builder.row(b)
|
||||
|
||||
is_renew_mode = data.get("renew_mode") == "renew"
|
||||
confirm_prefix = "cfg_renew_confirm" if is_renew_mode else "cfg_user_confirm"
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=CONFIG_PAY_BUTTON_TEXT.format(amount=price_text),
|
||||
callback_data=f"{confirm_prefix}|{tariff_id}",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await state.update_data(
|
||||
config_selected_device_limit=selected_devices,
|
||||
config_selected_traffic_gb=selected_traffic_gb,
|
||||
)
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=text,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
async def start_user_tariff_configurator(
|
||||
callback_query: CallbackQuery,
|
||||
session: Any,
|
||||
state: FSMContext,
|
||||
tariff: dict,
|
||||
):
|
||||
"""Запускает конфигуратор тарифа для пользователя."""
|
||||
cfg = normalize_tariff_config(tariff)
|
||||
|
||||
raw_device_options = cfg.get("device_options") or tariff.get("device_options") or []
|
||||
raw_traffic_options = cfg.get("traffic_options_gb") or tariff.get("traffic_options_gb") or []
|
||||
|
||||
device_int_options: list[int] = []
|
||||
for value in raw_device_options:
|
||||
try:
|
||||
device_int_options.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
traffic_int_options: list[int] = []
|
||||
for value in raw_traffic_options:
|
||||
try:
|
||||
traffic_int_options.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
base_device_limit = cfg.get("base_device_limit")
|
||||
if base_device_limit is None:
|
||||
base_device_limit = tariff.get("device_limit")
|
||||
if base_device_limit is not None:
|
||||
try:
|
||||
base_device_int = int(base_device_limit)
|
||||
if base_device_int not in device_int_options:
|
||||
raw_device_options.append(base_device_int)
|
||||
device_int_options.append(base_device_int)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
device_overrides_cfg = cfg.get("device_price_overrides") or tariff.get("device_overrides") or {}
|
||||
if "0" in device_overrides_cfg and 0 not in device_int_options:
|
||||
raw_device_options.append(0)
|
||||
device_int_options.append(0)
|
||||
|
||||
base_traffic_gb = cfg.get("base_traffic_gb")
|
||||
if base_traffic_gb is None:
|
||||
raw_limit = tariff.get("traffic_limit")
|
||||
if raw_limit:
|
||||
raw_limit = int(raw_limit)
|
||||
if raw_limit >= GB:
|
||||
base_traffic_gb = int(raw_limit / GB)
|
||||
else:
|
||||
base_traffic_gb = raw_limit
|
||||
if base_traffic_gb is not None:
|
||||
try:
|
||||
base_traffic_int = int(base_traffic_gb)
|
||||
if base_traffic_int not in traffic_int_options:
|
||||
raw_traffic_options.append(base_traffic_int)
|
||||
traffic_int_options.append(base_traffic_int)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
traffic_overrides_cfg = cfg.get("traffic_price_overrides") or tariff.get("traffic_overrides") or {}
|
||||
if "0" in traffic_overrides_cfg and 0 not in traffic_int_options:
|
||||
raw_traffic_options.append(0)
|
||||
traffic_int_options.append(0)
|
||||
|
||||
try:
|
||||
device_options = sorted(
|
||||
raw_device_options,
|
||||
key=lambda v: (int(v) == 0, int(v)),
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
device_options = raw_device_options
|
||||
|
||||
try:
|
||||
traffic_options = sorted(
|
||||
raw_traffic_options,
|
||||
key=lambda v: (int(v) == 0, int(v)),
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
traffic_options = raw_traffic_options
|
||||
|
||||
logger.info(
|
||||
"[TARIFF_CFG] start_user_tariff_configurator: "
|
||||
f"tg_id={callback_query.from_user.id} tariff_id={tariff.get('id')} "
|
||||
f"device_options={device_options} traffic_options={traffic_options}"
|
||||
)
|
||||
|
||||
if not device_options and not traffic_options:
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="❌ Конфигуратор для этого тарифа не настроен. Попробуйте выбрать другой тариф.",
|
||||
reply_markup=None,
|
||||
)
|
||||
await callback_query.answer()
|
||||
logger.warning(f"[TARIFF_CFG] configurator_not_configured: tariff_id={tariff.get('id')}")
|
||||
return
|
||||
|
||||
cfg_for_state = dict(cfg)
|
||||
if device_options:
|
||||
cfg_for_state["device_options"] = device_options
|
||||
if traffic_options:
|
||||
cfg_for_state["traffic_options_gb"] = traffic_options
|
||||
|
||||
data = await state.get_data()
|
||||
renew_mode = data.get("renew_mode")
|
||||
|
||||
update_payload: dict[str, Any] = {
|
||||
"config_tariff_id": tariff["id"],
|
||||
"tariff_config": cfg_for_state,
|
||||
}
|
||||
if renew_mode != "renew":
|
||||
update_payload["config_selected_device_limit"] = None
|
||||
update_payload["config_selected_traffic_gb"] = None
|
||||
|
||||
await state.update_data(**update_payload)
|
||||
await state.set_state(TariffUserConfigState.configuring)
|
||||
|
||||
await render_user_config_screen(callback_query, state, session)
|
||||
|
||||
|
||||
async def show_price_and_confirm(callback_query: CallbackQuery, state: FSMContext, session: Any | None):
|
||||
"""Обновляет экран конфигурации и показывает актуальную цену."""
|
||||
if session is None:
|
||||
from database import async_session_maker
|
||||
|
||||
async with async_session_maker() as new_session:
|
||||
await show_price_and_confirm(callback_query, state, new_session)
|
||||
return
|
||||
|
||||
await render_user_config_screen(callback_query, state, session)
|
||||
|
||||
|
||||
async def finalize_config_and_purchase(callback_query: CallbackQuery, state: FSMContext, session: Any | None):
|
||||
"""Фиксирует выбор пользователя и проводит оплату тарифа."""
|
||||
data = await state.get_data()
|
||||
tariff_id = data.get("config_tariff_id")
|
||||
cfg = data.get("tariff_config") or {}
|
||||
|
||||
if session is None:
|
||||
from database import async_session_maker
|
||||
|
||||
async with async_session_maker() as new_session:
|
||||
await finalize_config_and_purchase(callback_query, state, new_session)
|
||||
return
|
||||
|
||||
tariff = await get_tariff_by_id(session, int(tariff_id))
|
||||
if not tariff:
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="❌ Тариф не найден.",
|
||||
reply_markup=None,
|
||||
)
|
||||
await state.clear()
|
||||
logger.warning(f"[TARIFF_CFG] finalize_config_and_purchase tariff_not_found: tariff_id={tariff_id}")
|
||||
return
|
||||
|
||||
duration_days = int(tariff.get("duration_days") or 30)
|
||||
|
||||
raw_device_options = cfg.get("device_options") or tariff.get("device_options") or []
|
||||
try:
|
||||
device_options = sorted(
|
||||
raw_device_options,
|
||||
key=lambda v: (int(v) == 0, int(v)),
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
device_options = raw_device_options
|
||||
|
||||
raw_traffic_options = cfg.get("traffic_options_gb") or tariff.get("traffic_options_gb") or []
|
||||
try:
|
||||
traffic_options = sorted(
|
||||
raw_traffic_options,
|
||||
key=lambda v: (int(v) == 0, int(v)),
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
traffic_options = raw_traffic_options
|
||||
|
||||
device_int_options: list[int] = []
|
||||
for value in device_options:
|
||||
try:
|
||||
device_int_options.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
traffic_int_options: list[int] = []
|
||||
for value in traffic_options:
|
||||
try:
|
||||
traffic_int_options.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
has_device_choice = bool(device_int_options)
|
||||
has_traffic_choice = bool(traffic_int_options)
|
||||
|
||||
selected_devices = data.get("config_selected_device_limit")
|
||||
selected_traffic_gb = data.get("config_selected_traffic_gb")
|
||||
|
||||
base_device_limit = cfg.get("base_device_limit")
|
||||
if base_device_limit is None:
|
||||
base_device_limit = tariff.get("device_limit")
|
||||
if base_device_limit is None:
|
||||
positives = [v for v in device_int_options if v > 0]
|
||||
if positives:
|
||||
base_device_limit = min(positives)
|
||||
elif device_int_options:
|
||||
base_device_limit = device_int_options[0]
|
||||
base_device = int(base_device_limit) if base_device_limit is not None else None
|
||||
|
||||
base_traffic_gb = None
|
||||
raw_base_traffic = tariff.get("traffic_limit")
|
||||
if raw_base_traffic:
|
||||
raw_base_traffic = int(raw_base_traffic)
|
||||
if raw_base_traffic >= GB:
|
||||
base_traffic_gb = int(raw_base_traffic / GB)
|
||||
else:
|
||||
base_traffic_gb = raw_base_traffic
|
||||
if base_traffic_gb is None:
|
||||
cfg_base_traffic = cfg.get("base_traffic_gb")
|
||||
if cfg_base_traffic is not None and int(cfg_base_traffic) > 0:
|
||||
base_traffic_gb = int(cfg_base_traffic)
|
||||
if base_traffic_gb is None:
|
||||
positives = [v for v in traffic_int_options if v > 0]
|
||||
if positives:
|
||||
base_traffic_gb = min(positives)
|
||||
elif traffic_int_options:
|
||||
base_traffic_gb = traffic_int_options[0]
|
||||
base_traffic_gb = int(base_traffic_gb) if base_traffic_gb is not None else None
|
||||
|
||||
if has_device_choice:
|
||||
if selected_devices is None:
|
||||
if base_device is not None and base_device in device_int_options:
|
||||
selected_devices = base_device
|
||||
elif device_int_options:
|
||||
selected_devices = device_int_options[0]
|
||||
else:
|
||||
selected_devices = None
|
||||
|
||||
if has_traffic_choice:
|
||||
if selected_traffic_gb is None:
|
||||
if base_traffic_gb is not None and base_traffic_gb in traffic_int_options:
|
||||
selected_traffic_gb = base_traffic_gb
|
||||
elif traffic_int_options:
|
||||
selected_traffic_gb = traffic_int_options[0]
|
||||
else:
|
||||
selected_traffic_gb = None
|
||||
|
||||
final_price = calculate_config_price(
|
||||
tariff=tariff,
|
||||
selected_device_limit=int(selected_devices) if selected_devices is not None and has_device_choice else None,
|
||||
selected_traffic_gb=int(selected_traffic_gb)
|
||||
if selected_traffic_gb is not None and has_traffic_choice
|
||||
else None,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[TARIFF_CFG] finalize_config_and_purchase: "
|
||||
f"tg_id={callback_query.from_user.id} tariff_id={tariff_id} duration_days={duration_days} "
|
||||
f"selected_devices={selected_devices} selected_traffic_gb={selected_traffic_gb} "
|
||||
f"final_price={final_price}"
|
||||
)
|
||||
|
||||
await proceed_purchase_with_values(
|
||||
callback_query=callback_query,
|
||||
session=session,
|
||||
state=state,
|
||||
tariff=tariff,
|
||||
duration_days=duration_days,
|
||||
price_rub=final_price,
|
||||
selected_device_limit=int(selected_devices) if selected_devices is not None and has_device_choice else None,
|
||||
selected_traffic_gb=int(selected_traffic_gb)
|
||||
if selected_traffic_gb is not None and has_traffic_choice
|
||||
else None,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("select_tariff_plan|"))
|
||||
async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state: FSMContext):
|
||||
"""Обрабатывает выбор тарифа пользователем."""
|
||||
tg_id = callback_query.from_user.id
|
||||
tariff_id = int(callback_query.data.split("|")[1])
|
||||
|
||||
logger.info("[TARIFF_CFG] select_tariff_plan: tg_id=%s tariff_id=%s", tg_id, tariff_id)
|
||||
|
||||
tariff = await get_tariff_by_id(session, tariff_id)
|
||||
if not tariff:
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="❌ Указанный тариф не найден.",
|
||||
)
|
||||
await callback_query.answer()
|
||||
logger.warning(f"[TARIFF_CFG] select_tariff_plan tariff_not_found: tariff_id={tariff_id}")
|
||||
return
|
||||
|
||||
discount_info = await check_hot_lead_discount(session, tg_id)
|
||||
if tariff.get("group_code") in ["discounts", "discounts_max"]:
|
||||
if not discount_info.get("available") or datetime.utcnow() >= discount_info["expires_at"]:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="❌ Скидка недоступна или истекла. Пожалуйста, выберите тариф заново.",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await callback_query.answer()
|
||||
logger.info(
|
||||
"[TARIFF_CFG] select_tariff_plan discount_invalid: "
|
||||
f"tg_id={tg_id} tariff_id={tariff_id} info={discount_info}"
|
||||
)
|
||||
return
|
||||
|
||||
validity_result = await process_check_discount_validity(
|
||||
chat_id=tg_id,
|
||||
admin=False,
|
||||
session=session,
|
||||
tariff_group=tariff.get("group_code"),
|
||||
)
|
||||
if validity_result:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=validity_result["message"],
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await callback_query.answer()
|
||||
logger.info(
|
||||
"[TARIFF_CFG] select_tariff_plan discount_validity_failed: "
|
||||
f"tg_id={tg_id} tariff_id={tariff_id} result={validity_result}"
|
||||
)
|
||||
return
|
||||
|
||||
if tariff.get("configurable"):
|
||||
logger.info(f"[TARIFF_CFG] select_tariff_plan configurable: tg_id={tg_id} tariff_id={tariff_id}")
|
||||
try:
|
||||
await start_user_tariff_configurator(callback_query, session=session, state=state, tariff=tariff)
|
||||
except Exception as error:
|
||||
logger.error(f"[TARIFF_CFG] error_in_configurator: tariff_id={tariff_id} error={error}")
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="❌ Ошибка конфигурации тарифа. Попробуйте позже.",
|
||||
reply_markup=None,
|
||||
)
|
||||
return
|
||||
|
||||
duration_days = int(tariff.get("duration_days") or 30)
|
||||
price_rub = int(tariff.get("price_rub") or 0)
|
||||
selected_device_limit = tariff.get("device_limit")
|
||||
|
||||
raw_traffic_limit = tariff.get("traffic_limit")
|
||||
selected_traffic_gb = None
|
||||
if raw_traffic_limit:
|
||||
raw_traffic_limit = int(raw_traffic_limit)
|
||||
if raw_traffic_limit >= GB:
|
||||
selected_traffic_gb = int(raw_traffic_limit / GB)
|
||||
else:
|
||||
selected_traffic_gb = raw_traffic_limit
|
||||
|
||||
logger.info(
|
||||
"[TARIFF_CFG] select_tariff_plan fixed_tariff: "
|
||||
f"tg_id={tg_id} tariff_id={tariff_id} duration_days={duration_days} "
|
||||
f"price_rub={price_rub} device_limit={selected_device_limit} "
|
||||
f"selected_traffic_gb={selected_traffic_gb}"
|
||||
)
|
||||
|
||||
await proceed_purchase_with_values(
|
||||
callback_query=callback_query,
|
||||
session=session,
|
||||
state=state,
|
||||
tariff=tariff,
|
||||
duration_days=duration_days,
|
||||
price_rub=price_rub,
|
||||
selected_device_limit=selected_device_limit,
|
||||
selected_traffic_gb=selected_traffic_gb,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
F.data.startswith("cfg_user_devices|"),
|
||||
TariffUserConfigState.configuring,
|
||||
)
|
||||
async def handle_user_devices_choice(callback: CallbackQuery, state: FSMContext, session: Any):
|
||||
"""Обрабатывает выбор лимита устройств в конфигураторе."""
|
||||
_, _tariff_id_str, devices_str = callback.data.split("|", 2)
|
||||
devices = int(devices_str)
|
||||
|
||||
await state.update_data(config_selected_device_limit=devices)
|
||||
await render_user_config_screen(callback, state, session)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
F.data.startswith("cfg_user_traffic|"),
|
||||
TariffUserConfigState.configuring,
|
||||
)
|
||||
async def handle_user_traffic_choice(callback: CallbackQuery, state: FSMContext, session: Any):
|
||||
"""Обрабатывает выбор лимита трафика в конфигураторе."""
|
||||
_, _tariff_id_str, traffic_str = callback.data.split("|", 2)
|
||||
traffic = int(traffic_str)
|
||||
|
||||
await state.update_data(config_selected_traffic_gb=traffic)
|
||||
await render_user_config_screen(callback, state, session=session)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("cfg_user_confirm|"), TariffUserConfigState.configuring)
|
||||
async def handle_user_config_confirm(callback: CallbackQuery, state: FSMContext, session: Any):
|
||||
"""Подтверждает выбор параметров тарифа и запускает покупку."""
|
||||
logger.info(f"[TARIFF_CFG] handle_user_config_confirm: tg_id={callback.from_user.id}")
|
||||
await finalize_config_and_purchase(callback, state, session=session)
|
||||
@@ -0,0 +1,348 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD
|
||||
from core.settings.tariffs_config import TARIFFS_CONFIG, normalize_tariff_config
|
||||
from database import get_servers, get_tariff_by_id
|
||||
from database.models import Key
|
||||
from handlers.texts import key_message_success
|
||||
from logger import logger
|
||||
|
||||
|
||||
GB = 1024 * 1024 * 1024
|
||||
|
||||
|
||||
async def get_effective_limits_for_key(
|
||||
session: AsyncSession,
|
||||
tariff_id: int | None,
|
||||
selected_device_limit: int | None,
|
||||
selected_traffic_gb: int | None,
|
||||
) -> tuple[int, int]:
|
||||
tariff = await get_tariff_by_id(session, int(tariff_id)) if tariff_id else None
|
||||
|
||||
if tariff:
|
||||
base_devices = tariff.get("device_limit")
|
||||
base_traffic_bytes = tariff.get("traffic_limit")
|
||||
else:
|
||||
base_devices = None
|
||||
base_traffic_bytes = None
|
||||
|
||||
if selected_device_limit is None:
|
||||
device_limit = int(base_devices or 0)
|
||||
elif selected_device_limit == 0:
|
||||
device_limit = 0
|
||||
else:
|
||||
device_limit = int(selected_device_limit)
|
||||
|
||||
if selected_traffic_gb is None:
|
||||
traffic_limit_bytes = int(base_traffic_bytes or 0)
|
||||
elif selected_traffic_gb == 0:
|
||||
traffic_limit_bytes = 0
|
||||
else:
|
||||
traffic_limit_bytes = int(selected_traffic_gb) * GB
|
||||
|
||||
return device_limit, traffic_limit_bytes
|
||||
|
||||
|
||||
async def resolve_price_to_charge(session: AsyncSession, state_data: dict[str, Any]) -> int | None:
|
||||
price = state_data.get("selected_price_rub")
|
||||
if price is not None:
|
||||
try:
|
||||
return int(price)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
tariff_id = state_data.get("tariff_id")
|
||||
if not tariff_id:
|
||||
return None
|
||||
|
||||
tariff = await get_tariff_by_id(session, int(tariff_id))
|
||||
if not tariff:
|
||||
return None
|
||||
|
||||
try:
|
||||
return int(tariff.get("price_rub") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
async def resolve_vless_enabled(session: AsyncSession, tariff_id: int | None) -> bool:
|
||||
if not tariff_id:
|
||||
return False
|
||||
|
||||
tariff = await get_tariff_by_id(session, int(tariff_id))
|
||||
if not tariff:
|
||||
return False
|
||||
|
||||
return bool(tariff.get("vless"))
|
||||
|
||||
|
||||
async def get_key_tariff_display(
|
||||
session: AsyncSession,
|
||||
key_record: dict[str, Any],
|
||||
selected_device_limit_override: int | None = None,
|
||||
selected_traffic_gb_override: int | None = None,
|
||||
) -> tuple[str, str, int, int, bool]:
|
||||
tariff_id = key_record.get("tariff_id")
|
||||
if not tariff_id:
|
||||
return "", "", 0, 0, False
|
||||
|
||||
selected_device_limit = selected_device_limit_override
|
||||
selected_traffic_gb = selected_traffic_gb_override
|
||||
|
||||
if selected_device_limit is None:
|
||||
value = key_record.get("selected_device_limit")
|
||||
if value is not None:
|
||||
try:
|
||||
selected_device_limit = int(value)
|
||||
except (TypeError, ValueError):
|
||||
selected_device_limit = None
|
||||
|
||||
if selected_traffic_gb is None:
|
||||
value = key_record.get("selected_traffic_limit")
|
||||
if value is not None:
|
||||
try:
|
||||
selected_traffic_gb = int(value)
|
||||
except (TypeError, ValueError):
|
||||
selected_traffic_gb = None
|
||||
|
||||
device_limit, traffic_limit_bytes = await get_effective_limits_for_key(
|
||||
session=session,
|
||||
tariff_id=int(tariff_id),
|
||||
selected_device_limit=selected_device_limit,
|
||||
selected_traffic_gb=selected_traffic_gb,
|
||||
)
|
||||
|
||||
server_cluster_id = key_record.get("server_id")
|
||||
client_id = key_record.get("client_id")
|
||||
|
||||
if server_cluster_id and client_id:
|
||||
try:
|
||||
servers = await get_servers(session)
|
||||
cluster_servers = servers.get(server_cluster_id) or servers.get(str(server_cluster_id)) or []
|
||||
remna_server = next((srv for srv in cluster_servers if srv.get("panel_type") == "remnawave"), None)
|
||||
if not remna_server:
|
||||
remna_server = next(
|
||||
(srv for cl in servers.values() for srv in cl if srv.get("panel_type") == "remnawave"),
|
||||
None,
|
||||
)
|
||||
|
||||
if remna_server:
|
||||
from panels.remnawave import RemnawaveAPI
|
||||
|
||||
api = RemnawaveAPI(remna_server["api_url"])
|
||||
try:
|
||||
ok = await api.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD)
|
||||
except Exception as e:
|
||||
logger.warning(f"[KeyTariffDisplay] Remnawave login error for {client_id}: {e}")
|
||||
ok = False
|
||||
|
||||
if ok:
|
||||
try:
|
||||
user_data = await api.get_user_by_uuid(client_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"[KeyTariffDisplay] Remnawave get_user_by_uuid error for {client_id}: {e}")
|
||||
user_data = None
|
||||
|
||||
if user_data:
|
||||
panel_traffic_limit_bytes = user_data.get("trafficLimitBytes")
|
||||
panel_device_limit = user_data.get("hwidDeviceLimit")
|
||||
|
||||
if panel_traffic_limit_bytes is not None:
|
||||
try:
|
||||
traffic_limit_bytes = int(panel_traffic_limit_bytes)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
f"[KeyTariffDisplay] Invalid trafficLimitBytes from Remnawave for {client_id}: {panel_traffic_limit_bytes}"
|
||||
)
|
||||
|
||||
if panel_device_limit is not None:
|
||||
try:
|
||||
device_limit = int(panel_device_limit)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
f"[KeyTariffDisplay] Invalid hwidDeviceLimit from Remnawave for {client_id}: {panel_device_limit}"
|
||||
)
|
||||
|
||||
try:
|
||||
await api.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"[KeyTariffDisplay] Error while overriding limits from panel: {e}")
|
||||
|
||||
traffic_limit_gb = int(traffic_limit_bytes / GB) if traffic_limit_bytes else 0
|
||||
|
||||
tariff = await get_tariff_by_id(session, int(tariff_id))
|
||||
if tariff:
|
||||
tariff_name = tariff.get("name", "—")
|
||||
subgroup_title = tariff.get("subgroup_title") or ""
|
||||
vless_enabled = bool(tariff.get("vless"))
|
||||
else:
|
||||
tariff_name = "—"
|
||||
subgroup_title = ""
|
||||
vless_enabled = False
|
||||
|
||||
return tariff_name, subgroup_title, traffic_limit_gb, device_limit, vless_enabled
|
||||
|
||||
|
||||
async def get_key_tariff_addons_state(
|
||||
session: AsyncSession,
|
||||
key_record: dict[str, Any],
|
||||
db_key: Key | None,
|
||||
) -> tuple[str, str, int, int, bool, bool, bool, bool]:
|
||||
"""Возвращает параметры тарифа и допы для ключа."""
|
||||
tariff_id = key_record.get("tariff_id")
|
||||
if not tariff_id:
|
||||
return "", "", 0, 0, False, False, False, False
|
||||
|
||||
selected_device_limit_override: int | None = None
|
||||
selected_traffic_gb_override: int | None = None
|
||||
|
||||
if db_key:
|
||||
if db_key.selected_device_limit is not None:
|
||||
try:
|
||||
selected_device_limit_override = int(db_key.selected_device_limit)
|
||||
except (TypeError, ValueError):
|
||||
selected_device_limit_override = None
|
||||
if db_key.selected_traffic_limit is not None:
|
||||
try:
|
||||
selected_traffic_gb_override = int(db_key.selected_traffic_limit)
|
||||
except (TypeError, ValueError):
|
||||
selected_traffic_gb_override = None
|
||||
|
||||
(
|
||||
tariff_name,
|
||||
subgroup_title,
|
||||
traffic_limit_gb,
|
||||
device_limit,
|
||||
vless_enabled,
|
||||
) = await get_key_tariff_display(
|
||||
session=session,
|
||||
key_record=key_record,
|
||||
selected_device_limit_override=selected_device_limit_override,
|
||||
selected_traffic_gb_override=selected_traffic_gb_override,
|
||||
)
|
||||
|
||||
unlimited_devices = device_limit == 0
|
||||
unlimited_traffic = traffic_limit_gb == 0
|
||||
|
||||
if unlimited_devices or unlimited_traffic:
|
||||
suffix_parts: list[str] = []
|
||||
if unlimited_traffic:
|
||||
suffix_parts.append("безлимит трафика")
|
||||
if unlimited_devices:
|
||||
suffix_parts.append("безлимит устройств")
|
||||
tariff_name = f"{tariff_name} ({', '.join(suffix_parts)})"
|
||||
|
||||
is_tariff_configurable = False
|
||||
addons_devices_enabled = False
|
||||
addons_traffic_enabled = False
|
||||
|
||||
tariff = await get_tariff_by_id(session, int(tariff_id))
|
||||
if tariff and tariff.get("configurable"):
|
||||
is_tariff_configurable = True
|
||||
|
||||
cfg = normalize_tariff_config(tariff)
|
||||
device_options = cfg.get("device_options") or []
|
||||
traffic_options = cfg.get("traffic_options_gb") or []
|
||||
|
||||
addons_devices_enabled = bool(device_options)
|
||||
addons_traffic_enabled = bool(traffic_options)
|
||||
|
||||
mode = TARIFFS_CONFIG.get("KEY_ADDONS_PACK_MODE") or ""
|
||||
if not mode:
|
||||
pass
|
||||
elif mode == "traffic":
|
||||
addons_devices_enabled = False
|
||||
elif mode == "devices":
|
||||
addons_traffic_enabled = False
|
||||
elif mode == "all":
|
||||
pass
|
||||
else:
|
||||
logger.warning(f"Некорректный KEY_ADDONS_PACK_MODE: {mode!r}")
|
||||
|
||||
if unlimited_devices:
|
||||
addons_devices_enabled = False
|
||||
if unlimited_traffic:
|
||||
addons_traffic_enabled = False
|
||||
|
||||
return (
|
||||
tariff_name,
|
||||
subgroup_title,
|
||||
traffic_limit_gb,
|
||||
device_limit,
|
||||
vless_enabled,
|
||||
is_tariff_configurable,
|
||||
addons_devices_enabled,
|
||||
addons_traffic_enabled,
|
||||
)
|
||||
|
||||
|
||||
async def build_key_created_message(
|
||||
session: AsyncSession,
|
||||
key_record: dict[str, Any],
|
||||
final_link: str,
|
||||
selected_device_limit: int | None = None,
|
||||
selected_traffic_gb: int | None = None,
|
||||
) -> str:
|
||||
tariff_id = key_record.get("tariff_id")
|
||||
tariff = await get_tariff_by_id(session, int(tariff_id)) if tariff_id else None
|
||||
|
||||
if tariff:
|
||||
tariff_name = tariff.get("name", "—")
|
||||
subgroup_title = tariff.get("subgroup_title") or ""
|
||||
base_traffic = tariff.get("traffic_limit")
|
||||
base_devices = tariff.get("device_limit")
|
||||
else:
|
||||
tariff_name = "—"
|
||||
subgroup_title = ""
|
||||
base_traffic = None
|
||||
base_devices = None
|
||||
|
||||
if selected_traffic_gb is not None:
|
||||
try:
|
||||
traffic_to_show = int(selected_traffic_gb)
|
||||
except (TypeError, ValueError):
|
||||
traffic_to_show = 0
|
||||
else:
|
||||
selected_traffic_limit = key_record.get("selected_traffic_limit")
|
||||
if selected_traffic_limit is not None:
|
||||
try:
|
||||
traffic_to_show = int(selected_traffic_limit)
|
||||
except (TypeError, ValueError):
|
||||
traffic_to_show = 0
|
||||
elif base_traffic is not None:
|
||||
try:
|
||||
traffic_to_show = int(base_traffic / GB)
|
||||
except (TypeError, ValueError):
|
||||
traffic_to_show = 0
|
||||
else:
|
||||
traffic_to_show = 0
|
||||
|
||||
if selected_device_limit is not None:
|
||||
try:
|
||||
devices_to_show = int(selected_device_limit)
|
||||
except (TypeError, ValueError):
|
||||
devices_to_show = 0
|
||||
else:
|
||||
selected_device_limit_db = key_record.get("selected_device_limit")
|
||||
if selected_device_limit_db is not None:
|
||||
try:
|
||||
devices_to_show = int(selected_device_limit_db)
|
||||
except (TypeError, ValueError):
|
||||
devices_to_show = 0
|
||||
else:
|
||||
try:
|
||||
devices_to_show = int(base_devices) if base_devices is not None else 0
|
||||
except (TypeError, ValueError):
|
||||
devices_to_show = 0
|
||||
|
||||
return key_message_success(
|
||||
final_link or "Ссылка не найдена",
|
||||
tariff_name=tariff_name,
|
||||
traffic_limit=traffic_to_show,
|
||||
device_limit=devices_to_show,
|
||||
subgroup_title=subgroup_title,
|
||||
)
|
||||
+24
-24
@@ -1,6 +1,7 @@
|
||||
from typing import Any
|
||||
|
||||
from logger import logger
|
||||
|
||||
from .hooks import run_hooks
|
||||
|
||||
|
||||
@@ -13,7 +14,7 @@ async def process_cluster_override(
|
||||
) -> str | None:
|
||||
"""
|
||||
Обрабатывает хук cluster_override.
|
||||
|
||||
|
||||
Возвращает название кластера для принудительного выбора или None.
|
||||
"""
|
||||
try:
|
||||
@@ -38,7 +39,7 @@ async def process_cluster_balancer(
|
||||
) -> dict | None:
|
||||
"""
|
||||
Обрабатывает хук cluster_balancer.
|
||||
|
||||
|
||||
Возвращает отфильтрованный словарь кластеров или None (использовать исходный).
|
||||
"""
|
||||
try:
|
||||
@@ -62,7 +63,7 @@ async def process_remnawave_webapp_override(
|
||||
) -> bool:
|
||||
"""
|
||||
Обрабатывает хук remnawave_webapp_override.
|
||||
|
||||
|
||||
Возвращает bool - использовать ли webapp для подключения устройства.
|
||||
"""
|
||||
if not remnawave_webapp or not final_link:
|
||||
@@ -102,7 +103,7 @@ async def process_happ_cryptolink_override(
|
||||
) -> bool:
|
||||
"""
|
||||
Обрабатывает хук happ_cryptolink_override.
|
||||
|
||||
|
||||
Возвращает bool - использовать ли криптоссылку для подписки.
|
||||
"""
|
||||
try:
|
||||
@@ -141,15 +142,15 @@ async def process_extract_cryptolink_from_result(
|
||||
) -> str | None:
|
||||
"""
|
||||
Обрабатывает хук happ_cryptolink_override и извлекает криптоссылку из результата API.
|
||||
|
||||
|
||||
Возвращает криптоссылку если нужно использовать, иначе None.
|
||||
"""
|
||||
if need_vless_key:
|
||||
return None
|
||||
|
||||
try:
|
||||
from core.bootstrap import MODES_CONFIG
|
||||
from config import HAPP_CRYPTOLINK
|
||||
from core.bootstrap import MODES_CONFIG
|
||||
|
||||
base_use_crypto_link = bool(MODES_CONFIG.get("HAPP_CRYPTOLINK_ENABLED", HAPP_CRYPTOLINK))
|
||||
use_crypto_link = await process_happ_cryptolink_override(
|
||||
@@ -188,7 +189,7 @@ async def process_get_cryptolink_after_renewal(
|
||||
) -> str | None:
|
||||
"""
|
||||
Получает свежие данные подписки после продления и извлекает криптоссылку если нужно.
|
||||
|
||||
|
||||
Возвращает криптоссылку если хук требует её использования, иначе None.
|
||||
"""
|
||||
if not remnawave_nodes:
|
||||
@@ -196,9 +197,9 @@ async def process_get_cryptolink_after_renewal(
|
||||
|
||||
try:
|
||||
from config import REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD
|
||||
from panels.remnawave import RemnawaveAPI
|
||||
from database import get_tariff_by_id
|
||||
from handlers.keys.operations.utils import is_plan_vless
|
||||
from panels.remnawave import RemnawaveAPI
|
||||
|
||||
remna = RemnawaveAPI(remnawave_nodes[0]["api_url"])
|
||||
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
|
||||
@@ -237,7 +238,7 @@ async def process_intercept_key_creation_message(
|
||||
) -> bool:
|
||||
"""
|
||||
Обрабатывает хук intercept_key_creation_message.
|
||||
|
||||
|
||||
Возвращает True если нужно прервать выполнение (перехватить сообщение).
|
||||
"""
|
||||
try:
|
||||
@@ -264,7 +265,7 @@ async def process_key_creation_complete(
|
||||
) -> list:
|
||||
"""
|
||||
Обрабатывает хук key_creation_complete.
|
||||
|
||||
|
||||
Возвращает список кнопок для добавления в меню после создания ключа.
|
||||
"""
|
||||
try:
|
||||
@@ -291,7 +292,7 @@ async def process_process_callback_renew_key(
|
||||
) -> list:
|
||||
"""
|
||||
Обрабатывает хук process_callback_renew_key.
|
||||
|
||||
|
||||
Возвращает список кнопок для добавления в меню продления.
|
||||
"""
|
||||
try:
|
||||
@@ -316,7 +317,7 @@ async def process_renewal_forbidden_groups(
|
||||
) -> list[str]:
|
||||
"""
|
||||
Обрабатывает хук renewal_forbidden_groups.
|
||||
|
||||
|
||||
Возвращает список дополнительных запрещенных групп для продления.
|
||||
"""
|
||||
try:
|
||||
@@ -348,11 +349,11 @@ async def process_purchase_tariff_group_override(
|
||||
) -> dict | None:
|
||||
"""
|
||||
Обрабатывает хук purchase_tariff_group_override.
|
||||
|
||||
|
||||
Возвращает dict с ключами:
|
||||
- override_group: str - новая группа тарифов
|
||||
- discount_info: dict | None - информация о скидке (опционально)
|
||||
|
||||
|
||||
Или None если переопределение не требуется.
|
||||
"""
|
||||
try:
|
||||
@@ -384,7 +385,7 @@ async def process_renew_tariffs(
|
||||
) -> list:
|
||||
"""
|
||||
Обрабатывает хук renew_tariffs.
|
||||
|
||||
|
||||
Возвращает список кнопок для добавления в меню выбора тарифов для продления.
|
||||
"""
|
||||
try:
|
||||
@@ -411,7 +412,7 @@ async def process_renewal_complete(
|
||||
) -> list:
|
||||
"""
|
||||
Обрабатывает хук renewal_complete.
|
||||
|
||||
|
||||
Возвращает список кнопок для добавления в меню после продления подписки.
|
||||
"""
|
||||
try:
|
||||
@@ -437,7 +438,7 @@ async def process_view_key_menu(
|
||||
) -> list:
|
||||
"""
|
||||
Обрабатывает хук view_key_menu.
|
||||
|
||||
|
||||
Возвращает список кнопок для добавления в меню просмотра ключа.
|
||||
"""
|
||||
try:
|
||||
@@ -460,7 +461,7 @@ async def process_admin_key_edit_menu(
|
||||
) -> list:
|
||||
"""
|
||||
Обрабатывает хук admin_key_edit_menu.
|
||||
|
||||
|
||||
Возвращает список кнопок для добавления в меню редактирования ключа в админке.
|
||||
"""
|
||||
try:
|
||||
@@ -485,7 +486,7 @@ async def process_after_hwid_reset(
|
||||
) -> bool:
|
||||
"""
|
||||
Обрабатывает хук after_hwid_reset.
|
||||
|
||||
|
||||
Возвращает True если нужно перенаправить пользователя в профиль после сброса устройств.
|
||||
"""
|
||||
try:
|
||||
@@ -514,7 +515,7 @@ async def process_tariff_menu(
|
||||
) -> list:
|
||||
"""
|
||||
Обрабатывает хук tariff_menu.
|
||||
|
||||
|
||||
Возвращает список кнопок для добавления в меню выбора тарифов.
|
||||
"""
|
||||
try:
|
||||
@@ -541,11 +542,11 @@ async def process_check_discount_validity(
|
||||
) -> dict | None:
|
||||
"""
|
||||
Обрабатывает хук check_discount_validity.
|
||||
|
||||
|
||||
Возвращает dict с ключами:
|
||||
- valid: bool - валидна ли скидка
|
||||
- message: str - сообщение об ошибке (если valid=False)
|
||||
|
||||
|
||||
Или None если скидка валидна.
|
||||
"""
|
||||
try:
|
||||
@@ -577,7 +578,7 @@ async def process_connect_device_menu(
|
||||
) -> list:
|
||||
"""
|
||||
Обрабатывает хук connect_device_menu.
|
||||
|
||||
|
||||
Возвращает список кнопок для добавления в меню подключения устройства.
|
||||
"""
|
||||
try:
|
||||
@@ -592,4 +593,3 @@ async def process_connect_device_menu(
|
||||
except Exception as e:
|
||||
logger.warning(f"[CONNECT_DEVICE_MENU] Ошибка при обработке хука: {e}")
|
||||
return []
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from aiogram import BaseMiddleware
|
||||
from aiogram.types import CallbackQuery, Message, Update
|
||||
|
||||
from config import ADMIN_ID
|
||||
from core.bootstrap import MANAGEGENT_CONFIG
|
||||
from core.bootstrap import MANAGEMENT_CONFIG
|
||||
from database import async_session_maker
|
||||
from database.models import Admin
|
||||
|
||||
@@ -17,7 +17,7 @@ class MaintenanceModeMiddleware(BaseMiddleware):
|
||||
event: Update,
|
||||
data: dict[str, Any],
|
||||
) -> Any:
|
||||
maintenance_enabled = bool(MANAGEGENT_CONFIG.get("MAINTENANCE_ENABLED", False))
|
||||
maintenance_enabled = bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False))
|
||||
if not maintenance_enabled:
|
||||
return await handler(event, data)
|
||||
|
||||
|
||||
+1
-2
@@ -23,10 +23,9 @@ PING_SEMAPHORE = asyncio.Semaphore(3)
|
||||
|
||||
|
||||
async def ping_server(server_ip: str) -> bool:
|
||||
"""Пингует сервер через ICMP или TCP 443, если ICMP недоступен."""
|
||||
async with PING_SEMAPHORE:
|
||||
try:
|
||||
response = ping(server_ip, timeout=3)
|
||||
response = await asyncio.to_thread(ping, server_ip, timeout=3)
|
||||
if response is not None and response is not False:
|
||||
return True
|
||||
return await check_tcp_connection(server_ip, 443)
|
||||
|
||||
+1
-1
@@ -7,8 +7,8 @@ from aiogram.types import BufferedInputFile
|
||||
from sqlalchemy import exists, func, join, not_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Key, Payment, Referral, Tariff, User
|
||||
from core.constants import PAYMENT_SYSTEMS_EXCLUDED
|
||||
from database.models import Key, Payment, Referral, Tariff, User
|
||||
|
||||
|
||||
async def export_users_csv(session: AsyncSession) -> BufferedInputFile:
|
||||
|
||||
+1
-1
@@ -92,4 +92,4 @@ def get_git_commit_number() -> str:
|
||||
|
||||
|
||||
def get_version() -> str:
|
||||
return f"v.5.1-preAlpha {get_git_commit_number()}"
|
||||
return f"v.5.1-a291141 {get_git_commit_number()}"
|
||||
|
||||
Reference in New Issue
Block a user