feat: add admin HWID device limit overrides
This commit is contained in:
@@ -120,6 +120,8 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]:
|
|||||||
"regular_unlimited_override": regular_unlimited_override,
|
"regular_unlimited_override": regular_unlimited_override,
|
||||||
"premium_unlimited_override": premium_unlimited_override,
|
"premium_unlimited_override": premium_unlimited_override,
|
||||||
"premium_is_limited": bool(sub.premium_is_limited),
|
"premium_is_limited": bool(sub.premium_is_limited),
|
||||||
|
"hwid_device_limit": getattr(sub, "hwid_device_limit", None),
|
||||||
|
"extra_hwid_devices": int(getattr(sub, "extra_hwid_devices", 0) or 0),
|
||||||
"tariff_key": sub.tariff_key,
|
"tariff_key": sub.tariff_key,
|
||||||
"display_label": display_label,
|
"display_label": display_label,
|
||||||
"is_trial": is_trial,
|
"is_trial": is_trial,
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ def setup_admin_routes(app: web.Application) -> None:
|
|||||||
"/api/admin/users/{user_id:-?\\d+}/regular-traffic-override",
|
"/api/admin/users/{user_id:-?\\d+}/regular-traffic-override",
|
||||||
admin_user_regular_traffic_override_route,
|
admin_user_regular_traffic_override_route,
|
||||||
)
|
)
|
||||||
|
router.add_post(
|
||||||
|
"/api/admin/users/{user_id:-?\\d+}/hwid-device-limit",
|
||||||
|
admin_user_hwid_device_limit_route,
|
||||||
|
)
|
||||||
router.add_post(
|
router.add_post(
|
||||||
"/api/admin/users/{user_id:-?\\d+}/traffic-grant",
|
"/api/admin/users/{user_id:-?\\d+}/traffic-grant",
|
||||||
admin_user_traffic_grant_route,
|
admin_user_traffic_grant_route,
|
||||||
|
|||||||
@@ -1347,6 +1347,78 @@ async def admin_user_regular_traffic_override_route(request: web.Request) -> web
|
|||||||
return _ok({"subscription": _serialize_subscription(active)})
|
return _ok({"subscription": _serialize_subscription(active)})
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_user_hwid_device_limit_route(request: web.Request) -> web.Response:
|
||||||
|
"""Override the user's base HWID device limit.
|
||||||
|
|
||||||
|
``hwid_device_limit == 0`` means unlimited; ``NULL`` means the tariff/.env
|
||||||
|
default is used. Purchased extra devices remain tracked separately and are
|
||||||
|
added when syncing the effective panel limit.
|
||||||
|
"""
|
||||||
|
actor_id = _require_admin_user_id(request)
|
||||||
|
target_id = int(request.match_info["user_id"])
|
||||||
|
settings: Settings = request.app["settings"]
|
||||||
|
payload = await _read_json(request)
|
||||||
|
|
||||||
|
unlimited = bool(payload.get("unlimited"))
|
||||||
|
use_default = bool(payload.get("use_default") or payload.get("reset_to_default"))
|
||||||
|
limit_raw = payload.get("hwid_device_limit", payload.get("limit"))
|
||||||
|
|
||||||
|
if unlimited:
|
||||||
|
hwid_device_limit: Optional[int] = 0
|
||||||
|
elif use_default or limit_raw is None or limit_raw == "":
|
||||||
|
hwid_device_limit = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
hwid_device_limit = int(limit_raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return _error(
|
||||||
|
400,
|
||||||
|
"invalid_hwid_device_limit",
|
||||||
|
"hwid_device_limit must be a non-negative integer",
|
||||||
|
)
|
||||||
|
if hwid_device_limit < 0 or hwid_device_limit > 1_000_000:
|
||||||
|
return _error(
|
||||||
|
400,
|
||||||
|
"invalid_hwid_device_limit",
|
||||||
|
"hwid_device_limit must be an integer from 0 to 1000000",
|
||||||
|
)
|
||||||
|
|
||||||
|
subscription_service = request.app.get("subscription_service")
|
||||||
|
|
||||||
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
||||||
|
if not active:
|
||||||
|
return _error(404, "no_active_subscription")
|
||||||
|
|
||||||
|
active.hwid_device_limit = hwid_device_limit
|
||||||
|
|
||||||
|
effective_limit = None
|
||||||
|
if subscription_service is not None:
|
||||||
|
effective_limit = await subscription_service.sync_hwid_device_limit_to_panel(
|
||||||
|
session, target_id
|
||||||
|
)
|
||||||
|
|
||||||
|
await message_log_dal.create_message_log(
|
||||||
|
session,
|
||||||
|
{
|
||||||
|
"user_id": actor_id,
|
||||||
|
"event_type": "admin_hwid_device_limit_webapp",
|
||||||
|
"content": (
|
||||||
|
f"hwid_device_limit={hwid_device_limit!r} "
|
||||||
|
f"effective_hwid_device_limit={effective_limit!r}"
|
||||||
|
),
|
||||||
|
"is_admin_event": True,
|
||||||
|
"target_user_id": target_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(active)
|
||||||
|
|
||||||
|
await _invalidate_after_admin_user_mutation(settings, target_id)
|
||||||
|
return _ok({"subscription": _serialize_subscription(active)})
|
||||||
|
|
||||||
|
|
||||||
async def admin_user_traffic_grant_route(request: web.Request) -> web.Response:
|
async def admin_user_traffic_grant_route(request: web.Request) -> web.Response:
|
||||||
"""Credit regular or premium traffic to a user without a payment.
|
"""Credit regular or premium traffic to a user without a payment.
|
||||||
|
|
||||||
|
|||||||
@@ -779,9 +779,31 @@ def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]:
|
|||||||
def _resolve_app_version() -> str:
|
def _resolve_app_version() -> str:
|
||||||
# Single source of truth shared with the telemetry worker so the admin
|
# Single source of truth shared with the telemetry worker so the admin
|
||||||
# sidebar and the install beacon always report the same version.
|
# sidebar and the install beacon always report the same version.
|
||||||
from bot.utils.app_version import resolve_app_version
|
from bot.utils import app_version as app_version_module
|
||||||
|
|
||||||
return resolve_app_version()
|
global _APP_VERSION_CACHE
|
||||||
|
|
||||||
|
app_version_module.APP_ROOT = APP_ROOT
|
||||||
|
app_version_module._run_git_command = _run_git_command
|
||||||
|
app_version_module._APP_VERSION_CACHE = _APP_VERSION_CACHE
|
||||||
|
version = app_version_module.resolve_app_version()
|
||||||
|
_APP_VERSION_CACHE = app_version_module._APP_VERSION_CACHE
|
||||||
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
def _run_git_command(*args: str) -> str:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", *args],
|
||||||
|
cwd=APP_ROOT,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=1.5,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return ""
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
async def _enforce_webapp_rate_limit(
|
async def _enforce_webapp_rate_limit(
|
||||||
|
|||||||
@@ -342,11 +342,12 @@ def _serialize_subscription(
|
|||||||
and tariff.premium_topup_packages.has_any()
|
and tariff.premium_topup_packages.has_any()
|
||||||
)
|
)
|
||||||
can_topup_traffic = bool(can_topup_regular_traffic or can_topup_premium_traffic)
|
can_topup_traffic = bool(can_topup_regular_traffic or can_topup_premium_traffic)
|
||||||
# max_devices == 0 means unlimited — top-up is pointless in that case.
|
max_devices = _coerce_int_or_none(active.get("max_devices"))
|
||||||
|
# max_devices == 0 or None means unlimited — top-up is pointless in that case.
|
||||||
can_topup_devices = bool(
|
can_topup_devices = bool(
|
||||||
tariff.billing_model == "period"
|
tariff.billing_model == "period"
|
||||||
and tariff.has_hwid_device_packages()
|
and tariff.has_hwid_device_packages()
|
||||||
and _coerce_int_or_none(active.get("max_devices")) != 0
|
and max_devices not in (None, 0)
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
can_topup_regular_traffic = False
|
can_topup_regular_traffic = False
|
||||||
|
|||||||
@@ -236,6 +236,10 @@ def get_user_card_keyboard(
|
|||||||
text=_(key="admin_user_traffic_grant_button"),
|
text=_(key="admin_user_traffic_grant_button"),
|
||||||
callback_data=f"user_action:traffic_grant:{user_id}",
|
callback_data=f"user_action:traffic_grant:{user_id}",
|
||||||
)
|
)
|
||||||
|
builder.button(
|
||||||
|
text=_(key="admin_user_hwid_limit_button"),
|
||||||
|
callback_data=f"user_action:hwid_limit:{user_id}",
|
||||||
|
)
|
||||||
|
|
||||||
# Row 4: Quick links — only for users with a real Telegram profile
|
# Row 4: Quick links — only for users with a real Telegram profile
|
||||||
# (synthetic email-only users have a negative user_id with no tg profile).
|
# (synthetic email-only users have a negative user_id with no tg profile).
|
||||||
@@ -261,9 +265,9 @@ def get_user_card_keyboard(
|
|||||||
|
|
||||||
quick_links_count = (1 if has_self_link else 0) + (1 if has_referrer_link else 0)
|
quick_links_count = (1 if has_self_link else 0) + (1 if has_referrer_link else 0)
|
||||||
if quick_links_count == 0:
|
if quick_links_count == 0:
|
||||||
builder.adjust(2, 2, 2, 1, 2, 1, 2)
|
builder.adjust(2, 2, 2, 1, 3, 1, 2)
|
||||||
else:
|
else:
|
||||||
builder.adjust(2, 2, 2, 1, 2, quick_links_count, 1, 2)
|
builder.adjust(2, 2, 2, 1, 3, quick_links_count, 1, 2)
|
||||||
return builder
|
return builder
|
||||||
|
|
||||||
|
|
||||||
@@ -403,6 +407,26 @@ async def format_user_card(
|
|||||||
f"{_('admin_user_traffic_label')} {hcode(f'{used_display} / {limit_display}')}"
|
f"{_('admin_user_traffic_label')} {hcode(f'{used_display} / {limit_display}')}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
max_devices = subscription_details.get("max_devices")
|
||||||
|
extra_hwid_devices = int(subscription_details.get("extra_hwid_devices") or 0)
|
||||||
|
if max_devices is not None:
|
||||||
|
if int(max_devices) == 0:
|
||||||
|
devices_display = _("admin_hwid_limit_state_unlimited")
|
||||||
|
elif extra_hwid_devices > 0:
|
||||||
|
base_hwid_limit = subscription_details.get("base_hwid_device_limit")
|
||||||
|
if base_hwid_limit is None:
|
||||||
|
devices_display = _("admin_hwid_limit_state_count", count=int(max_devices))
|
||||||
|
else:
|
||||||
|
devices_display = _(
|
||||||
|
"admin_hwid_limit_state_with_extra",
|
||||||
|
total=int(max_devices),
|
||||||
|
base=int(base_hwid_limit),
|
||||||
|
extra=extra_hwid_devices,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
devices_display = _("admin_hwid_limit_state_count", count=int(max_devices))
|
||||||
|
card_parts.append(f"{_('admin_user_hwid_limit_label')} {hcode(devices_display)}")
|
||||||
|
|
||||||
premium_unlimited = bool(subscription_details.get("premium_unlimited_override"))
|
premium_unlimited = bool(subscription_details.get("premium_unlimited_override"))
|
||||||
premium_bonus_bytes = int(subscription_details.get("premium_bonus_bytes") or 0)
|
premium_bonus_bytes = int(subscription_details.get("premium_bonus_bytes") or 0)
|
||||||
if premium_unlimited:
|
if premium_unlimited:
|
||||||
@@ -706,6 +730,32 @@ async def user_action_handler(
|
|||||||
await handle_traffic_grant_prompt(callback, state, user, "regular", i18n, current_lang)
|
await handle_traffic_grant_prompt(callback, state, user, "regular", i18n, current_lang)
|
||||||
elif action == "traffic_grant_premium":
|
elif action == "traffic_grant_premium":
|
||||||
await handle_traffic_grant_prompt(callback, state, user, "premium", i18n, current_lang)
|
await handle_traffic_grant_prompt(callback, state, user, "premium", i18n, current_lang)
|
||||||
|
elif action == "hwid_limit":
|
||||||
|
await handle_hwid_limit_menu(callback, state, user, session, i18n, current_lang)
|
||||||
|
elif action == "hwid_limit_set_unlimited":
|
||||||
|
await handle_hwid_limit_apply(
|
||||||
|
callback,
|
||||||
|
user,
|
||||||
|
subscription_service,
|
||||||
|
session,
|
||||||
|
settings,
|
||||||
|
i18n,
|
||||||
|
current_lang,
|
||||||
|
hwid_device_limit=0,
|
||||||
|
)
|
||||||
|
elif action == "hwid_limit_reset":
|
||||||
|
await handle_hwid_limit_apply(
|
||||||
|
callback,
|
||||||
|
user,
|
||||||
|
subscription_service,
|
||||||
|
session,
|
||||||
|
settings,
|
||||||
|
i18n,
|
||||||
|
current_lang,
|
||||||
|
hwid_device_limit=None,
|
||||||
|
)
|
||||||
|
elif action == "hwid_limit_set_number":
|
||||||
|
await handle_hwid_limit_prompt(callback, state, user, i18n, current_lang)
|
||||||
else:
|
else:
|
||||||
await callback.answer(_("admin_unknown_action"), show_alert=True)
|
await callback.answer(_("admin_unknown_action"), show_alert=True)
|
||||||
|
|
||||||
@@ -850,6 +900,162 @@ async def handle_premium_override_bonus_prompt(
|
|||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_hwid_limit_state_text(
|
||||||
|
get_text: Callable[..., str],
|
||||||
|
hwid_device_limit: Optional[int],
|
||||||
|
extra_hwid_devices: int = 0,
|
||||||
|
) -> str:
|
||||||
|
if hwid_device_limit is None:
|
||||||
|
return get_text("admin_hwid_limit_state_default")
|
||||||
|
base_limit = int(hwid_device_limit)
|
||||||
|
if base_limit == 0:
|
||||||
|
return get_text("admin_hwid_limit_state_unlimited")
|
||||||
|
extra = max(0, int(extra_hwid_devices or 0))
|
||||||
|
if extra > 0:
|
||||||
|
return get_text(
|
||||||
|
"admin_hwid_limit_state_with_extra",
|
||||||
|
total=base_limit + extra,
|
||||||
|
base=base_limit,
|
||||||
|
extra=extra,
|
||||||
|
)
|
||||||
|
return get_text("admin_hwid_limit_state_count", count=base_limit)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_hwid_limit_menu(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
state: FSMContext,
|
||||||
|
user: User,
|
||||||
|
session: AsyncSession,
|
||||||
|
i18n_instance,
|
||||||
|
lang: str,
|
||||||
|
) -> None:
|
||||||
|
"""Show HWID device limit override controls."""
|
||||||
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
|
|
||||||
|
active_sub = await subscription_dal.get_active_subscription_by_user_id(session, user.user_id)
|
||||||
|
if not active_sub:
|
||||||
|
await callback.answer(_("admin_hwid_limit_no_subscription"), show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
current_text = _admin_hwid_limit_state_text(
|
||||||
|
_,
|
||||||
|
getattr(active_sub, "hwid_device_limit", None),
|
||||||
|
int(getattr(active_sub, "extra_hwid_devices", 0) or 0),
|
||||||
|
)
|
||||||
|
text = "\n".join(
|
||||||
|
[
|
||||||
|
f"<b>{_('admin_hwid_limit_title')}</b>",
|
||||||
|
"",
|
||||||
|
_("admin_hwid_limit_hint"),
|
||||||
|
"",
|
||||||
|
_("admin_hwid_limit_current", current=current_text),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.button(
|
||||||
|
text=_("admin_hwid_limit_btn_set_number"),
|
||||||
|
callback_data=f"user_action:hwid_limit_set_number:{user.user_id}",
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text=_("admin_hwid_limit_btn_unlimited"),
|
||||||
|
callback_data=f"user_action:hwid_limit_set_unlimited:{user.user_id}",
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text=_("admin_hwid_limit_btn_reset"),
|
||||||
|
callback_data=f"user_action:hwid_limit_reset:{user.user_id}",
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text=_("admin_user_back_to_card_button"),
|
||||||
|
callback_data=f"user_action:refresh:{user.user_id}",
|
||||||
|
)
|
||||||
|
builder.adjust(1, 1, 1, 1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(text, reply_markup=builder.as_markup(), parse_mode="HTML")
|
||||||
|
except Exception:
|
||||||
|
await callback.message.answer(text, reply_markup=builder.as_markup(), parse_mode="HTML")
|
||||||
|
await state.update_data(target_user_id=user.user_id)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_hwid_limit_apply(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
user: User,
|
||||||
|
subscription_service: SubscriptionService,
|
||||||
|
session: AsyncSession,
|
||||||
|
settings: Settings,
|
||||||
|
i18n_instance,
|
||||||
|
lang: str,
|
||||||
|
*,
|
||||||
|
hwid_device_limit: Optional[int],
|
||||||
|
) -> None:
|
||||||
|
"""Persist a HWID device base limit override and push it to the panel."""
|
||||||
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||||
|
session, user.user_id
|
||||||
|
)
|
||||||
|
if not active_sub:
|
||||||
|
await callback.answer(_("admin_hwid_limit_no_subscription"), show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
active_sub.hwid_device_limit = hwid_device_limit
|
||||||
|
|
||||||
|
effective_limit = await subscription_service.sync_hwid_device_limit_to_panel(
|
||||||
|
session, user.user_id
|
||||||
|
)
|
||||||
|
await message_log_dal.create_message_log_no_commit(
|
||||||
|
session,
|
||||||
|
{
|
||||||
|
"user_id": callback.from_user.id if callback.from_user else user.user_id,
|
||||||
|
"event_type": "admin:hwid_device_limit",
|
||||||
|
"content": (
|
||||||
|
f"hwid_device_limit={hwid_device_limit!r} "
|
||||||
|
f"effective_hwid_device_limit={effective_limit!r}"
|
||||||
|
),
|
||||||
|
"is_admin_event": True,
|
||||||
|
"target_user_id": user.user_id,
|
||||||
|
"timestamp": datetime.now(timezone.utc),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
await callback.answer(_("admin_hwid_limit_saved"), show_alert=False)
|
||||||
|
await handle_refresh_user_card(
|
||||||
|
callback, user, subscription_service, session, settings, i18n_instance, lang
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logging.error(
|
||||||
|
"Failed to apply HWID device limit for user %s: %s",
|
||||||
|
user.user_id,
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
await session.rollback()
|
||||||
|
await callback.answer(_("admin_hwid_limit_save_error"), show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_hwid_limit_prompt(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
state: FSMContext,
|
||||||
|
user: User,
|
||||||
|
i18n_instance,
|
||||||
|
lang: str,
|
||||||
|
) -> None:
|
||||||
|
"""Ask admin for an explicit HWID device limit."""
|
||||||
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
|
await state.update_data(target_user_id=user.user_id)
|
||||||
|
await state.set_state(AdminStates.waiting_for_hwid_device_limit)
|
||||||
|
prompt = _("admin_hwid_limit_prompt", user_id=user.user_id)
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(prompt)
|
||||||
|
except Exception:
|
||||||
|
await callback.message.answer(prompt)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
async def handle_traffic_grant_menu(
|
async def handle_traffic_grant_menu(
|
||||||
callback: types.CallbackQuery,
|
callback: types.CallbackQuery,
|
||||||
user: User,
|
user: User,
|
||||||
@@ -1971,6 +2177,114 @@ async def process_premium_override_bonus_handler(
|
|||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(AdminStates.waiting_for_hwid_device_limit, F.text)
|
||||||
|
async def process_hwid_device_limit_handler(
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
settings: Settings,
|
||||||
|
i18n_data: dict,
|
||||||
|
subscription_service: SubscriptionService,
|
||||||
|
session: AsyncSession,
|
||||||
|
):
|
||||||
|
"""Read explicit HWID device limit and apply it."""
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
if not i18n:
|
||||||
|
await message.reply("Language service error.")
|
||||||
|
return
|
||||||
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||||
|
|
||||||
|
data = await state.get_data()
|
||||||
|
target_user_id = data.get("target_user_id")
|
||||||
|
if not target_user_id:
|
||||||
|
await message.answer(_("admin_hwid_limit_state_missing"))
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
raw = (message.text or "").strip()
|
||||||
|
try:
|
||||||
|
hwid_device_limit = int(raw)
|
||||||
|
if hwid_device_limit < 0 or hwid_device_limit > 1_000_000:
|
||||||
|
raise ValueError("out_of_range")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
await message.answer(_("admin_hwid_limit_invalid"))
|
||||||
|
return
|
||||||
|
|
||||||
|
target_user = await user_dal.get_user_by_id(session, target_user_id)
|
||||||
|
if not target_user:
|
||||||
|
await message.answer(_("admin_user_not_found_action"))
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||||
|
session, target_user_id
|
||||||
|
)
|
||||||
|
if not active_sub:
|
||||||
|
await message.answer(_("admin_hwid_limit_no_subscription"))
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
active_sub.hwid_device_limit = hwid_device_limit
|
||||||
|
effective_limit = await subscription_service.sync_hwid_device_limit_to_panel(
|
||||||
|
session, target_user_id
|
||||||
|
)
|
||||||
|
await message_log_dal.create_message_log_no_commit(
|
||||||
|
session,
|
||||||
|
{
|
||||||
|
"user_id": message.from_user.id if message.from_user else target_user_id,
|
||||||
|
"event_type": "admin:hwid_device_limit",
|
||||||
|
"content": (
|
||||||
|
f"hwid_device_limit={hwid_device_limit!r} "
|
||||||
|
f"effective_hwid_device_limit={effective_limit!r}"
|
||||||
|
),
|
||||||
|
"is_admin_event": True,
|
||||||
|
"target_user_id": target_user_id,
|
||||||
|
"timestamp": datetime.now(timezone.utc),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
current_text = _admin_hwid_limit_state_text(_, hwid_device_limit)
|
||||||
|
await message.answer(
|
||||||
|
_("admin_hwid_limit_set", current=current_text, user_id=target_user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
referral_service = ReferralService(settings, subscription_service, message.bot, i18n)
|
||||||
|
bot_username = await _resolve_bot_username(message.bot)
|
||||||
|
user_card_text = await format_user_card(
|
||||||
|
target_user,
|
||||||
|
session,
|
||||||
|
subscription_service,
|
||||||
|
i18n,
|
||||||
|
current_lang,
|
||||||
|
referral_service,
|
||||||
|
settings=settings,
|
||||||
|
bot_username=bot_username,
|
||||||
|
)
|
||||||
|
keyboard = get_user_card_keyboard(
|
||||||
|
target_user.user_id, i18n, current_lang, target_user.referred_by_id
|
||||||
|
)
|
||||||
|
await _send_with_profile_link_fallback(
|
||||||
|
message.answer,
|
||||||
|
text=user_card_text,
|
||||||
|
markup=keyboard.as_markup(),
|
||||||
|
user_id=target_user.user_id,
|
||||||
|
parse_mode="HTML",
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logging.error(
|
||||||
|
"Error setting HWID device limit for user %s: %s",
|
||||||
|
target_user_id,
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
await session.rollback()
|
||||||
|
await message.answer(_("admin_hwid_limit_save_error"))
|
||||||
|
finally:
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
@router.message(AdminStates.waiting_for_traffic_grant_gb, F.text)
|
@router.message(AdminStates.waiting_for_traffic_grant_gb, F.text)
|
||||||
async def process_traffic_grant_gb_handler(
|
async def process_traffic_grant_gb_handler(
|
||||||
message: types.Message,
|
message: types.Message,
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ LOCALE_GROUPS = [
|
|||||||
"prefixes": (
|
"prefixes": (
|
||||||
"admin_user_",
|
"admin_user_",
|
||||||
"admin_users_",
|
"admin_users_",
|
||||||
|
"admin_hwid_",
|
||||||
"admin_ban_",
|
"admin_ban_",
|
||||||
"admin_unban_",
|
"admin_unban_",
|
||||||
"admin_banned_",
|
"admin_banned_",
|
||||||
@@ -115,6 +116,7 @@ LOCALE_GROUPS = [
|
|||||||
"admin_traffic_grant_",
|
"admin_traffic_grant_",
|
||||||
"admin_view_banned_",
|
"admin_view_banned_",
|
||||||
"user_card_",
|
"user_card_",
|
||||||
|
"user_hwid_",
|
||||||
"user_premium_",
|
"user_premium_",
|
||||||
"user_regular_",
|
"user_regular_",
|
||||||
"user_traffic_",
|
"user_traffic_",
|
||||||
|
|||||||
@@ -31,6 +31,49 @@ class HwidDeviceMixin:
|
|||||||
)
|
)
|
||||||
return int(getattr(sub, "extra_hwid_devices", 0) or 0)
|
return int(getattr(sub, "extra_hwid_devices", 0) or 0)
|
||||||
|
|
||||||
|
async def sync_hwid_device_limit_to_panel(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
) -> Optional[int]:
|
||||||
|
"""Push the current local HWID device limit override to the panel."""
|
||||||
|
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||||
|
if not db_user or not db_user.panel_user_uuid:
|
||||||
|
return None
|
||||||
|
sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||||
|
session, user_id, db_user.panel_user_uuid
|
||||||
|
)
|
||||||
|
if not sub:
|
||||||
|
return None
|
||||||
|
|
||||||
|
tariff = self._resolve_tariff(sub.tariff_key) if sub.tariff_key else None
|
||||||
|
base_hwid_limit = (
|
||||||
|
int(sub.hwid_device_limit)
|
||||||
|
if sub.hwid_device_limit is not None
|
||||||
|
else self._base_hwid_limit_for_tariff(tariff)
|
||||||
|
)
|
||||||
|
extra_hwid_devices = await self._active_hwid_extra_devices_for_sub(session, sub)
|
||||||
|
sub.extra_hwid_devices = extra_hwid_devices
|
||||||
|
effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices)
|
||||||
|
if effective_hwid_limit is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
panel_payload = self._build_panel_update_payload(
|
||||||
|
panel_user_uuid=db_user.panel_user_uuid,
|
||||||
|
expire_at=sub.end_date,
|
||||||
|
status="ACTIVE",
|
||||||
|
hwid_device_limit=effective_hwid_limit,
|
||||||
|
include_default_squads=False,
|
||||||
|
)
|
||||||
|
panel_payload.update(self._panel_identity_payload_for_user(db_user))
|
||||||
|
try:
|
||||||
|
await self.panel_service.update_user_details_on_panel(
|
||||||
|
db_user.panel_user_uuid, panel_payload
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logging.exception("sync_hwid_device_limit_to_panel failed for user %s", user_id)
|
||||||
|
return effective_hwid_limit
|
||||||
|
|
||||||
async def _hwid_topup_validity_window(
|
async def _hwid_topup_validity_window(
|
||||||
self,
|
self,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
@@ -150,7 +193,7 @@ class HwidDeviceMixin:
|
|||||||
if sub.hwid_device_limit is not None
|
if sub.hwid_device_limit is not None
|
||||||
else self._base_hwid_limit_for_tariff(tariff)
|
else self._base_hwid_limit_for_tariff(tariff)
|
||||||
)
|
)
|
||||||
if base_hwid_limit == 0:
|
if base_hwid_limit in (None, 0):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
package = self._find_hwid_package(tariff, purchased_devices, currency)
|
package = self._find_hwid_package(tariff, purchased_devices, currency)
|
||||||
@@ -248,7 +291,7 @@ class HwidDeviceMixin:
|
|||||||
if sub.hwid_device_limit is not None
|
if sub.hwid_device_limit is not None
|
||||||
else self._base_hwid_limit_for_tariff(tariff)
|
else self._base_hwid_limit_for_tariff(tariff)
|
||||||
)
|
)
|
||||||
if base_hwid_limit == 0:
|
if base_hwid_limit in (None, 0):
|
||||||
logging.info(
|
logging.info(
|
||||||
"Skipping HWID top-up for user %s because current limit is unlimited", user_id
|
"Skipping HWID top-up for user %s because current limit is unlimited", user_id
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -314,7 +314,7 @@ class TariffMixin:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _effective_hwid_limit(base_limit: Optional[int], extra_devices: int = 0) -> Optional[int]:
|
def _effective_hwid_limit(base_limit: Optional[int], extra_devices: int = 0) -> Optional[int]:
|
||||||
if base_limit is None:
|
if base_limit is None:
|
||||||
return None
|
return 0
|
||||||
base_int = max(0, int(base_limit))
|
base_int = max(0, int(base_limit))
|
||||||
if base_int == 0:
|
if base_int == 0:
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class AdminStates(StatesGroup):
|
|||||||
waiting_for_user_delete_confirmation = State()
|
waiting_for_user_delete_confirmation = State()
|
||||||
waiting_for_premium_override_bonus_gb = State()
|
waiting_for_premium_override_bonus_gb = State()
|
||||||
waiting_for_traffic_grant_gb = State()
|
waiting_for_traffic_grant_gb = State()
|
||||||
|
waiting_for_hwid_device_limit = State()
|
||||||
|
|
||||||
# Ads campaigns
|
# Ads campaigns
|
||||||
waiting_for_ad_source = State()
|
waiting_for_ad_source = State()
|
||||||
|
|||||||
@@ -73,6 +73,23 @@
|
|||||||
return trial.active ? `${base} · ${at("user_trial_active", {}, "активен")}` : base;
|
return trial.active ? `${base} · ${at("user_trial_active", {}, "активен")}` : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hwidLimitLabel(sub) {
|
||||||
|
const rawBase = sub?.hwid_device_limit;
|
||||||
|
const hasBase = rawBase !== null && rawBase !== undefined;
|
||||||
|
const extra = Math.max(0, Number(sub?.extra_hwid_devices || 0));
|
||||||
|
if (!hasBase) return at("user_hwid_limit_default", {}, "Тарифный / default");
|
||||||
|
const base = Number(rawBase);
|
||||||
|
if (base === 0) return at("user_hwid_limit_unlimited", {}, "Безлимит");
|
||||||
|
if (extra > 0) {
|
||||||
|
return at(
|
||||||
|
"user_hwid_limit_with_extra",
|
||||||
|
{ base, extra, total: base + extra },
|
||||||
|
`${base + extra} (${base} + ${extra})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return at("user_hwid_limit_count", { count: base }, `${base}`);
|
||||||
|
}
|
||||||
|
|
||||||
const usersStore = getContext("usersStore");
|
const usersStore = getContext("usersStore");
|
||||||
|
|
||||||
$: ({
|
$: ({
|
||||||
@@ -91,6 +108,7 @@
|
|||||||
userReferralsPage,
|
userReferralsPage,
|
||||||
userReferralsPageSize,
|
userReferralsPageSize,
|
||||||
premiumUnlimitedDraft,
|
premiumUnlimitedDraft,
|
||||||
|
hwidUnlimitedDraft,
|
||||||
userDetailTab,
|
userDetailTab,
|
||||||
userLogs,
|
userLogs,
|
||||||
userLogsTotal,
|
userLogsTotal,
|
||||||
@@ -437,6 +455,11 @@
|
|||||||
>{openedUserDetail.active_subscription.provider || "—"}</strong
|
>{openedUserDetail.active_subscription.provider || "—"}</strong
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<span>{at("user_label_hwid_devices", {}, "HWID-устройства")}</span><strong
|
||||||
|
>{hwidLimitLabel(openedUserDetail.active_subscription)}</strong
|
||||||
|
>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="admin-traffic-summary">
|
<div class="admin-traffic-summary">
|
||||||
<AdminTrafficCard
|
<AdminTrafficCard
|
||||||
@@ -897,6 +920,66 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="admin-user-action-sheet admin-user-action-sheet--hwid-limit">
|
||||||
|
<AdminSectionHeader
|
||||||
|
title={at("user_hwid_limit_card_title", {}, "HWID-устройства")}
|
||||||
|
description={at(
|
||||||
|
"user_hwid_limit_card_hint",
|
||||||
|
{},
|
||||||
|
"Ручной лимит устройств для пользователя. Пустое поле вернёт тарифный или default-лимит."
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div class="admin-user-action-sheet-body admin-user-override-stack">
|
||||||
|
<Label.Root class="admin-field-label admin-extend-field">
|
||||||
|
<span>{at("user_hwid_limit_input", {}, "Лимит устройств")}</span>
|
||||||
|
<small
|
||||||
|
>{at(
|
||||||
|
"user_hwid_limit_input_hint",
|
||||||
|
{},
|
||||||
|
"Пусто — тариф/default; 0 или галочка — безлимит."
|
||||||
|
)}</small
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
class="input"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
placeholder={at("user_hwid_limit_default_placeholder", {}, "Тариф")}
|
||||||
|
disabled={hwidUnlimitedDraft}
|
||||||
|
aria-label={at("user_hwid_limit_input", {}, "Лимит устройств")}
|
||||||
|
bind:value={$usersStore.hwidDeviceLimitDraft}
|
||||||
|
/>
|
||||||
|
</Label.Root>
|
||||||
|
</div>
|
||||||
|
<div class="admin-user-action-sheet-footer admin-override-card-footer">
|
||||||
|
<div class="admin-override-card-toolbar">
|
||||||
|
<label class="admin-override-unlimited-label">
|
||||||
|
<Checkbox
|
||||||
|
bind:checked={$usersStore.hwidUnlimitedDraft}
|
||||||
|
aria-label={at("user_override_unlimited_short", {}, "Безлимит")}
|
||||||
|
/>
|
||||||
|
<span>{at("user_override_unlimited_short", {}, "Безлимит")}</span>
|
||||||
|
</label>
|
||||||
|
<AdminButton
|
||||||
|
variant="primary"
|
||||||
|
onclick={usersStore.saveHwidDeviceLimit}
|
||||||
|
disabled={userActionBusy}
|
||||||
|
>
|
||||||
|
{at("user_hwid_limit_save", {}, "Сохранить")}
|
||||||
|
</AdminButton>
|
||||||
|
</div>
|
||||||
|
<div class="admin-override-status-lines">
|
||||||
|
<span class="admin-meta-truncate">
|
||||||
|
{at(
|
||||||
|
"user_hwid_limit_status",
|
||||||
|
{ current: hwidLimitLabel(openedUserDetail.active_subscription) },
|
||||||
|
`Сейчас: ${hwidLimitLabel(openedUserDetail.active_subscription)}`
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="admin-user-action-sheet admin-user-action-sheet--traffic-grant">
|
<section class="admin-user-action-sheet admin-user-action-sheet--traffic-grant">
|
||||||
<AdminSectionHeader
|
<AdminSectionHeader
|
||||||
title={at("user_traffic_grant_title", {}, "Выдать трафик")}
|
title={at("user_traffic_grant_title", {}, "Выдать трафик")}
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
|
|||||||
premiumBonusGbDraft: "",
|
premiumBonusGbDraft: "",
|
||||||
regularUnlimitedDraft: false,
|
regularUnlimitedDraft: false,
|
||||||
regularBonusGbDraft: "",
|
regularBonusGbDraft: "",
|
||||||
|
hwidUnlimitedDraft: false,
|
||||||
|
hwidDeviceLimitDraft: "",
|
||||||
grantTrafficGbDraft: "",
|
grantTrafficGbDraft: "",
|
||||||
grantTrafficKindDraft: "regular",
|
grantTrafficKindDraft: "regular",
|
||||||
|
|
||||||
@@ -154,6 +156,9 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
|
|||||||
const sub = res.active_subscription || null;
|
const sub = res.active_subscription || null;
|
||||||
const bonusBytes = Number(sub?.premium_bonus_bytes || 0);
|
const bonusBytes = Number(sub?.premium_bonus_bytes || 0);
|
||||||
const regularBonusBytes = Number(sub?.regular_bonus_bytes || 0);
|
const regularBonusBytes = Number(sub?.regular_bonus_bytes || 0);
|
||||||
|
const hasHwidLimit =
|
||||||
|
sub?.hwid_device_limit !== null && sub?.hwid_device_limit !== undefined;
|
||||||
|
const hwidLimit = hasHwidLimit ? Number(sub?.hwid_device_limit) : null;
|
||||||
state.update((s) => ({
|
state.update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
openedUserDetail: res,
|
openedUserDetail: res,
|
||||||
@@ -163,6 +168,8 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
|
|||||||
regularUnlimitedDraft: Boolean(sub?.regular_unlimited_override),
|
regularUnlimitedDraft: Boolean(sub?.regular_unlimited_override),
|
||||||
regularBonusGbDraft:
|
regularBonusGbDraft:
|
||||||
regularBonusBytes > 0 ? +(regularBonusBytes / 1024 ** 3).toFixed(2) : "",
|
regularBonusBytes > 0 ? +(regularBonusBytes / 1024 ** 3).toFixed(2) : "",
|
||||||
|
hwidUnlimitedDraft: hasHwidLimit && hwidLimit === 0,
|
||||||
|
hwidDeviceLimitDraft: hasHwidLimit && hwidLimit > 0 ? String(hwidLimit) : "",
|
||||||
grantTrafficGbDraft: "",
|
grantTrafficGbDraft: "",
|
||||||
grantTrafficKindDraft: "regular",
|
grantTrafficKindDraft: "regular",
|
||||||
}));
|
}));
|
||||||
@@ -551,6 +558,51 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveHwidDeviceLimit() {
|
||||||
|
let s;
|
||||||
|
state.update((st) => {
|
||||||
|
s = st;
|
||||||
|
return st;
|
||||||
|
});
|
||||||
|
if (!s.openedUser) return;
|
||||||
|
state.update((st) => ({ ...st, userActionBusy: true }));
|
||||||
|
try {
|
||||||
|
const unlimited = Boolean(s.hwidUnlimitedDraft);
|
||||||
|
const raw = s.hwidDeviceLimitDraft;
|
||||||
|
const useDefault = !unlimited && (raw === "" || raw === null || raw === undefined);
|
||||||
|
let limit = null;
|
||||||
|
if (!unlimited && !useDefault) {
|
||||||
|
limit = Number(raw);
|
||||||
|
if (!Number.isInteger(limit) || limit < 0 || limit > 1_000_000) {
|
||||||
|
onToast(
|
||||||
|
at(
|
||||||
|
"hwid_limit_invalid",
|
||||||
|
{},
|
||||||
|
"Введите целое число устройств от 0 до 1 000 000 или включите безлимит"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const res = await api(`/admin/users/${s.openedUser.user_id}/hwid-device-limit`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
unlimited,
|
||||||
|
use_default: useDefault,
|
||||||
|
hwid_device_limit: unlimited ? 0 : limit,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res?.ok) {
|
||||||
|
onToast(at("hwid_limit_saved", {}, "Лимит устройств сохранён"));
|
||||||
|
await openUser(s.openedUser, { skipPush: true });
|
||||||
|
} else {
|
||||||
|
onToast(res?.error || at("error", {}, "Ошибка"));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
state.update((st) => ({ ...st, userActionBusy: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function grantTraffic() {
|
async function grantTraffic() {
|
||||||
let s;
|
let s;
|
||||||
state.update((st) => {
|
state.update((st) => {
|
||||||
@@ -635,6 +687,7 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
|
|||||||
deleteUser,
|
deleteUser,
|
||||||
savePremiumTrafficOverride,
|
savePremiumTrafficOverride,
|
||||||
saveRegularTrafficOverride,
|
saveRegularTrafficOverride,
|
||||||
|
saveHwidDeviceLimit,
|
||||||
grantTraffic,
|
grantTraffic,
|
||||||
loadUserLogs,
|
loadUserLogs,
|
||||||
setUserLogsPage,
|
setUserLogsPage,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const DEMO_DATASET = {
|
|||||||
payments: 482,
|
payments: 482,
|
||||||
logs: 1600,
|
logs: 1600,
|
||||||
supportTickets: 3,
|
supportTickets: 3,
|
||||||
translationKeys: 1961,
|
translationKeys: 1994,
|
||||||
settingsFields: 223,
|
settingsFields: 223,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -11088,8 +11088,8 @@ export const DEMO_DATASET = {
|
|||||||
provider: "trial",
|
provider: "trial",
|
||||||
is_throttled: false,
|
is_throttled: false,
|
||||||
install_share_token: "1a0d471f37fdbd7429931a9a75d6e221",
|
install_share_token: "1a0d471f37fdbd7429931a9a75d6e221",
|
||||||
hwid_device_limit: 0,
|
hwid_device_limit: 4,
|
||||||
extra_hwid_devices: 0,
|
extra_hwid_devices: 2,
|
||||||
},
|
},
|
||||||
subscriptions: [
|
subscriptions: [
|
||||||
{
|
{
|
||||||
@@ -11122,8 +11122,8 @@ export const DEMO_DATASET = {
|
|||||||
provider: "trial",
|
provider: "trial",
|
||||||
is_throttled: false,
|
is_throttled: false,
|
||||||
install_share_token: "1a0d471f37fdbd7429931a9a75d6e221",
|
install_share_token: "1a0d471f37fdbd7429931a9a75d6e221",
|
||||||
hwid_device_limit: 0,
|
hwid_device_limit: 4,
|
||||||
extra_hwid_devices: 0,
|
extra_hwid_devices: 2,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
total_paid: 0,
|
total_paid: 0,
|
||||||
@@ -105180,6 +105180,812 @@ export const DEMO_DATASET = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "admin_user_hwid_limit_button",
|
||||||
|
audience: "internal",
|
||||||
|
values: {
|
||||||
|
ru: {
|
||||||
|
base: "📱 HWID лимит",
|
||||||
|
fallback: "📱 HWID лимит",
|
||||||
|
effective: "📱 HWID лимит",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
base: "📱 HWID limit",
|
||||||
|
fallback: "📱 HWID лимит",
|
||||||
|
effective: "📱 HWID limit",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "admin_user_hwid_limit_label",
|
||||||
|
audience: "internal",
|
||||||
|
values: {
|
||||||
|
ru: {
|
||||||
|
base: "📱 HWID-устройства:",
|
||||||
|
fallback: "📱 HWID-устройства:",
|
||||||
|
effective: "📱 HWID-устройства:",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
base: "📱 HWID devices:",
|
||||||
|
fallback: "📱 HWID-устройства:",
|
||||||
|
effective: "📱 HWID devices:",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "admin_hwid_limit_title",
|
||||||
|
audience: "internal",
|
||||||
|
values: {
|
||||||
|
ru: {
|
||||||
|
base: "📱 Лимит HWID-устройств",
|
||||||
|
fallback: "📱 Лимит HWID-устройств",
|
||||||
|
effective: "📱 Лимит HWID-устройств",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
base: "📱 HWID device limit",
|
||||||
|
fallback: "📱 Лимит HWID-устройств",
|
||||||
|
effective: "📱 HWID device limit",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "admin_hwid_limit_hint",
|
||||||
|
audience: "internal",
|
||||||
|
values: {
|
||||||
|
ru: {
|
||||||
|
base: "Задайте ручной базовый лимит устройств для пользователя. 0 означает безлимит; сброс вернёт тарифный или default-лимит.",
|
||||||
|
fallback:
|
||||||
|
"Задайте ручной базовый лимит устройств для пользователя. 0 означает безлимит; сброс вернёт тарифный или default-лимит.",
|
||||||
|
effective:
|
||||||
|
"Задайте ручной базовый лимит устройств для пользователя. 0 означает безлимит; сброс вернёт тарифный или default-лимит.",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
base: "Set a manual base device limit for this user. 0 means unlimited; reset returns to the tariff or default limit.",
|
||||||
|
fallback:
|
||||||
|
"Задайте ручной базовый лимит устройств для пользователя. 0 означает безлимит; сброс вернёт тарифный или default-лимит.",
|
||||||
|
effective:
|
||||||
|
"Set a manual base device limit for this user. 0 means unlimited; reset returns to the tariff or default limit.",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "admin_hwid_limit_current",
|
||||||
|
audience: "internal",
|
||||||
|
values: {
|
||||||
|
ru: {
|
||||||
|
base: "Сейчас: {current}",
|
||||||
|
fallback: "Сейчас: {current}",
|
||||||
|
effective: "Сейчас: {current}",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
base: "Current: {current}",
|
||||||
|
fallback: "Сейчас: {current}",
|
||||||
|
effective: "Current: {current}",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "admin_hwid_limit_state_default",
|
||||||
|
audience: "internal",
|
||||||
|
values: {
|
||||||
|
ru: {
|
||||||
|
base: "тарифный / default",
|
||||||
|
fallback: "тарифный / default",
|
||||||
|
effective: "тарифный / default",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
base: "tariff / default",
|
||||||
|
fallback: "тарифный / default",
|
||||||
|
effective: "tariff / default",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "admin_hwid_limit_state_unlimited",
|
||||||
|
audience: "internal",
|
||||||
|
values: {
|
||||||
|
ru: {
|
||||||
|
base: "безлимит",
|
||||||
|
fallback: "безлимит",
|
||||||
|
effective: "безлимит",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
base: "unlimited",
|
||||||
|
fallback: "безлимит",
|
||||||
|
effective: "unlimited",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "admin_hwid_limit_state_count",
|
||||||
|
audience: "internal",
|
||||||
|
values: {
|
||||||
|
ru: {
|
||||||
|
base: "{count} устройств",
|
||||||
|
fallback: "{count} устройств",
|
||||||
|
effective: "{count} устройств",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
base: "{count} devices",
|
||||||
|
fallback: "{count} устройств",
|
||||||
|
effective: "{count} devices",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "admin_hwid_limit_state_with_extra",
|
||||||
|
audience: "internal",
|
||||||
|
values: {
|
||||||
|
ru: {
|
||||||
|
base: "{total} устройств ({base} + {extra})",
|
||||||
|
fallback: "{total} устройств ({base} + {extra})",
|
||||||
|
effective: "{total} устройств ({base} + {extra})",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
base: "{total} devices ({base} + {extra})",
|
||||||
|
fallback: "{total} устройств ({base} + {extra})",
|
||||||
|
effective: "{total} devices ({base} + {extra})",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "admin_hwid_limit_btn_set_number",
|
||||||
|
audience: "internal",
|
||||||
|
values: {
|
||||||
|
ru: {
|
||||||
|
base: "🔢 Задать число",
|
||||||
|
fallback: "🔢 Задать число",
|
||||||
|
effective: "🔢 Задать число",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
base: "🔢 Set number",
|
||||||
|
fallback: "🔢 Задать число",
|
||||||
|
effective: "🔢 Set number",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "admin_hwid_limit_btn_unlimited",
|
||||||
|
audience: "internal",
|
||||||
|
values: {
|
||||||
|
ru: {
|
||||||
|
base: "♾ Включить безлимит",
|
||||||
|
fallback: "♾ Включить безлимит",
|
||||||
|
effective: "♾ Включить безлимит",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
base: "♾ Make unlimited",
|
||||||
|
fallback: "♾ Включить безлимит",
|
||||||
|
effective: "♾ Make unlimited",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "admin_hwid_limit_btn_reset",
|
||||||
|
audience: "internal",
|
||||||
|
values: {
|
||||||
|
ru: {
|
||||||
|
base: "↩️ Вернуть тариф/default",
|
||||||
|
fallback: "↩️ Вернуть тариф/default",
|
||||||
|
effective: "↩️ Вернуть тариф/default",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
base: "↩️ Use tariff/default",
|
||||||
|
fallback: "↩️ Вернуть тариф/default",
|
||||||
|
effective: "↩️ Use tariff/default",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "admin_hwid_limit_prompt",
|
||||||
|
audience: "internal",
|
||||||
|
values: {
|
||||||
|
ru: {
|
||||||
|
base: "Введите лимит HWID-устройств для пользователя {user_id}. 0 означает безлимит:",
|
||||||
|
fallback:
|
||||||
|
"Введите лимит HWID-устройств для пользователя {user_id}. 0 означает безлимит:",
|
||||||
|
effective:
|
||||||
|
"Введите лимит HWID-устройств для пользователя {user_id}. 0 означает безлимит:",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
base: "Enter the HWID device limit for user {user_id}. 0 means unlimited:",
|
||||||
|
fallback:
|
||||||
|
"Введите лимит HWID-устройств для пользователя {user_id}. 0 означает безлимит:",
|
||||||
|
effective: "Enter the HWID device limit for user {user_id}. 0 means unlimited:",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "admin_hwid_limit_invalid",
|
||||||
|
audience: "internal",
|
||||||
|
values: {
|
||||||
|
ru: {
|
||||||
|
base: "❌ Введите целое число устройств от 0 до 1 000 000.",
|
||||||
|
fallback: "❌ Введите целое число устройств от 0 до 1 000 000.",
|
||||||
|
effective: "❌ Введите целое число устройств от 0 до 1 000 000.",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
base: "❌ Enter an integer device count from 0 to 1,000,000.",
|
||||||
|
fallback: "❌ Введите целое число устройств от 0 до 1 000 000.",
|
||||||
|
effective: "❌ Enter an integer device count from 0 to 1,000,000.",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "admin_hwid_limit_no_subscription",
|
||||||
|
audience: "internal",
|
||||||
|
values: {
|
||||||
|
ru: {
|
||||||
|
base: "У пользователя нет активной подписки",
|
||||||
|
fallback: "У пользователя нет активной подписки",
|
||||||
|
effective: "У пользователя нет активной подписки",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
base: "User has no active subscription",
|
||||||
|
fallback: "У пользователя нет активной подписки",
|
||||||
|
effective: "User has no active subscription",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "admin_hwid_limit_state_missing",
|
||||||
|
audience: "internal",
|
||||||
|
values: {
|
||||||
|
ru: {
|
||||||
|
base: "⚠️ Целевой пользователь не найден в состоянии. Откройте карточку пользователя заново.",
|
||||||
|
fallback:
|
||||||
|
"⚠️ Целевой пользователь не найден в состоянии. Откройте карточку пользователя заново.",
|
||||||
|
effective:
|
||||||
|
"⚠️ Целевой пользователь не найден в состоянии. Откройте карточку пользователя заново.",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
base: "⚠️ Target user missing in state. Open the user card again.",
|
||||||
|
fallback:
|
||||||
|
"⚠️ Целевой пользователь не найден в состоянии. Откройте карточку пользователя заново.",
|
||||||
|
effective: "⚠️ Target user missing in state. Open the user card again.",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "admin_hwid_limit_set",
|
||||||
|
audience: "internal",
|
||||||
|
values: {
|
||||||
|
ru: {
|
||||||
|
base: "✅ Лимит HWID-устройств для пользователя {user_id}: {current}",
|
||||||
|
fallback: "✅ Лимит HWID-устройств для пользователя {user_id}: {current}",
|
||||||
|
effective: "✅ Лимит HWID-устройств для пользователя {user_id}: {current}",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
base: "✅ HWID device limit for user {user_id}: {current}",
|
||||||
|
fallback: "✅ Лимит HWID-устройств для пользователя {user_id}: {current}",
|
||||||
|
effective: "✅ HWID device limit for user {user_id}: {current}",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "admin_hwid_limit_saved",
|
||||||
|
audience: "internal",
|
||||||
|
values: {
|
||||||
|
ru: {
|
||||||
|
base: "✅ Лимит HWID-устройств сохранён",
|
||||||
|
fallback: "✅ Лимит HWID-устройств сохранён",
|
||||||
|
effective: "✅ Лимит HWID-устройств сохранён",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
base: "✅ HWID device limit saved",
|
||||||
|
fallback: "✅ Лимит HWID-устройств сохранён",
|
||||||
|
effective: "✅ HWID device limit saved",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "admin_hwid_limit_save_error",
|
||||||
|
audience: "internal",
|
||||||
|
values: {
|
||||||
|
ru: {
|
||||||
|
base: "❌ Не удалось сохранить лимит HWID-устройств",
|
||||||
|
fallback: "❌ Не удалось сохранить лимит HWID-устройств",
|
||||||
|
effective: "❌ Не удалось сохранить лимит HWID-устройств",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
base: "❌ Failed to save HWID device limit",
|
||||||
|
fallback: "❌ Не удалось сохранить лимит HWID-устройств",
|
||||||
|
effective: "❌ Failed to save HWID device limit",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "user_label_hwid_devices",
|
||||||
|
audience: "internal",
|
||||||
|
values: {
|
||||||
|
ru: {
|
||||||
|
base: "HWID-устройства",
|
||||||
|
fallback: "HWID-устройства",
|
||||||
|
effective: "HWID-устройства",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
base: "HWID devices",
|
||||||
|
fallback: "HWID-устройства",
|
||||||
|
effective: "HWID devices",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "user_hwid_limit_card_title",
|
||||||
|
audience: "internal",
|
||||||
|
values: {
|
||||||
|
ru: {
|
||||||
|
base: "HWID-устройства",
|
||||||
|
fallback: "HWID-устройства",
|
||||||
|
effective: "HWID-устройства",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
base: "HWID devices",
|
||||||
|
fallback: "HWID-устройства",
|
||||||
|
effective: "HWID devices",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "user_hwid_limit_card_hint",
|
||||||
|
audience: "internal",
|
||||||
|
values: {
|
||||||
|
ru: {
|
||||||
|
base: "Ручной лимит устройств для пользователя. Пустое поле вернёт тарифный или default-лимит.",
|
||||||
|
fallback:
|
||||||
|
"Ручной лимит устройств для пользователя. Пустое поле вернёт тарифный или default-лимит.",
|
||||||
|
effective:
|
||||||
|
"Ручной лимит устройств для пользователя. Пустое поле вернёт тарифный или default-лимит.",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
base: "Manual device limit for this user. Empty value returns to the tariff or default limit.",
|
||||||
|
fallback:
|
||||||
|
"Ручной лимит устройств для пользователя. Пустое поле вернёт тарифный или default-лимит.",
|
||||||
|
effective:
|
||||||
|
"Manual device limit for this user. Empty value returns to the tariff or default limit.",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "user_hwid_limit_input",
|
||||||
|
audience: "internal",
|
||||||
|
values: {
|
||||||
|
ru: {
|
||||||
|
base: "Лимит устройств",
|
||||||
|
fallback: "Лимит устройств",
|
||||||
|
effective: "Лимит устройств",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
base: "Device limit",
|
||||||
|
fallback: "Лимит устройств",
|
||||||
|
effective: "Device limit",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "user_hwid_limit_input_hint",
|
||||||
|
audience: "internal",
|
||||||
|
values: {
|
||||||
|
ru: {
|
||||||
|
base: "Пусто — тариф/default; 0 или галочка — безлимит.",
|
||||||
|
fallback: "Пусто — тариф/default; 0 или галочка — безлимит.",
|
||||||
|
effective: "Пусто — тариф/default; 0 или галочка — безлимит.",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
base: "Empty means tariff/default; 0 or the checkbox means unlimited.",
|
||||||
|
fallback: "Пусто — тариф/default; 0 или галочка — безлимит.",
|
||||||
|
effective: "Empty means tariff/default; 0 or the checkbox means unlimited.",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "user_hwid_limit_default_placeholder",
|
||||||
|
audience: "internal",
|
||||||
|
values: {
|
||||||
|
ru: {
|
||||||
|
base: "Тариф",
|
||||||
|
fallback: "Тариф",
|
||||||
|
effective: "Тариф",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
base: "Tariff",
|
||||||
|
fallback: "Тариф",
|
||||||
|
effective: "Tariff",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "user_hwid_limit_save",
|
||||||
|
audience: "internal",
|
||||||
|
values: {
|
||||||
|
ru: {
|
||||||
|
base: "Сохранить",
|
||||||
|
fallback: "Сохранить",
|
||||||
|
effective: "Сохранить",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
base: "Save",
|
||||||
|
fallback: "Сохранить",
|
||||||
|
effective: "Save",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "user_hwid_limit_status",
|
||||||
|
audience: "internal",
|
||||||
|
values: {
|
||||||
|
ru: {
|
||||||
|
base: "Сейчас: {current}",
|
||||||
|
fallback: "Сейчас: {current}",
|
||||||
|
effective: "Сейчас: {current}",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
base: "Current: {current}",
|
||||||
|
fallback: "Сейчас: {current}",
|
||||||
|
effective: "Current: {current}",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "user_hwid_limit_default",
|
||||||
|
audience: "internal",
|
||||||
|
values: {
|
||||||
|
ru: {
|
||||||
|
base: "Тарифный / default",
|
||||||
|
fallback: "Тарифный / default",
|
||||||
|
effective: "Тарифный / default",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
base: "Tariff / default",
|
||||||
|
fallback: "Тарифный / default",
|
||||||
|
effective: "Tariff / default",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "user_hwid_limit_unlimited",
|
||||||
|
audience: "internal",
|
||||||
|
values: {
|
||||||
|
ru: {
|
||||||
|
base: "Безлимит",
|
||||||
|
fallback: "Безлимит",
|
||||||
|
effective: "Безлимит",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
base: "Unlimited",
|
||||||
|
fallback: "Безлимит",
|
||||||
|
effective: "Unlimited",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "user_hwid_limit_count",
|
||||||
|
audience: "internal",
|
||||||
|
values: {
|
||||||
|
ru: {
|
||||||
|
base: "{count} устройств",
|
||||||
|
fallback: "{count} устройств",
|
||||||
|
effective: "{count} устройств",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
base: "{count} devices",
|
||||||
|
fallback: "{count} устройств",
|
||||||
|
effective: "{count} devices",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "user_hwid_limit_with_extra",
|
||||||
|
audience: "internal",
|
||||||
|
values: {
|
||||||
|
ru: {
|
||||||
|
base: "{total} устройств ({base} + {extra})",
|
||||||
|
fallback: "{total} устройств ({base} + {extra})",
|
||||||
|
effective: "{total} устройств ({base} + {extra})",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
base: "{total} devices ({base} + {extra})",
|
||||||
|
fallback: "{total} устройств ({base} + {extra})",
|
||||||
|
effective: "{total} devices ({base} + {extra})",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "hwid_limit_saved",
|
||||||
|
audience: "internal",
|
||||||
|
values: {
|
||||||
|
ru: {
|
||||||
|
base: "Лимит устройств сохранён",
|
||||||
|
fallback: "Лимит устройств сохранён",
|
||||||
|
effective: "Лимит устройств сохранён",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
base: "Device limit saved",
|
||||||
|
fallback: "Лимит устройств сохранён",
|
||||||
|
effective: "Device limit saved",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "hwid_limit_invalid",
|
||||||
|
audience: "internal",
|
||||||
|
values: {
|
||||||
|
ru: {
|
||||||
|
base: "Введите целое число устройств от 0 до 1 000 000 или включите безлимит",
|
||||||
|
fallback: "Введите целое число устройств от 0 до 1 000 000 или включите безлимит",
|
||||||
|
effective: "Введите целое число устройств от 0 до 1 000 000 или включите безлимит",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
base: "Enter an integer device count from 0 to 1,000,000 or enable unlimited",
|
||||||
|
fallback: "Введите целое число устройств от 0 до 1 000 000 или включите безлимит",
|
||||||
|
effective: "Enter an integer device count from 0 to 1,000,000 or enable unlimited",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -284,6 +284,8 @@
|
|||||||
"admin_user_premium_override_label": "🌟 Premium override:",
|
"admin_user_premium_override_label": "🌟 Premium override:",
|
||||||
"admin_user_premium_override_unlimited": "Unlimited",
|
"admin_user_premium_override_unlimited": "Unlimited",
|
||||||
"admin_user_premium_override_bonus_value": "+{gb} GB",
|
"admin_user_premium_override_bonus_value": "+{gb} GB",
|
||||||
|
"admin_user_hwid_limit_button": "📱 HWID limit",
|
||||||
|
"admin_user_hwid_limit_label": "📱 HWID devices:",
|
||||||
"admin_premium_override_title": "🌟 Premium override",
|
"admin_premium_override_title": "🌟 Premium override",
|
||||||
"admin_premium_override_hint": "Grant free extra premium traffic or unlimited premium access for this user.",
|
"admin_premium_override_hint": "Grant free extra premium traffic or unlimited premium access for this user.",
|
||||||
"admin_premium_override_current": "Current: {current}",
|
"admin_premium_override_current": "Current: {current}",
|
||||||
@@ -300,6 +302,23 @@
|
|||||||
"admin_premium_override_bonus_set": "✅ Premium override: +{gb} GB for user {user_id}",
|
"admin_premium_override_bonus_set": "✅ Premium override: +{gb} GB for user {user_id}",
|
||||||
"admin_premium_override_saved": "✅ Premium override saved",
|
"admin_premium_override_saved": "✅ Premium override saved",
|
||||||
"admin_premium_override_save_error": "❌ Failed to save premium override",
|
"admin_premium_override_save_error": "❌ Failed to save premium override",
|
||||||
|
"admin_hwid_limit_title": "📱 HWID device limit",
|
||||||
|
"admin_hwid_limit_hint": "Set a manual base device limit for this user. 0 means unlimited; reset returns to the tariff or default limit.",
|
||||||
|
"admin_hwid_limit_current": "Current: {current}",
|
||||||
|
"admin_hwid_limit_state_default": "tariff / default",
|
||||||
|
"admin_hwid_limit_state_unlimited": "unlimited",
|
||||||
|
"admin_hwid_limit_state_count": "{count} devices",
|
||||||
|
"admin_hwid_limit_state_with_extra": "{total} devices ({base} + {extra})",
|
||||||
|
"admin_hwid_limit_btn_set_number": "🔢 Set number",
|
||||||
|
"admin_hwid_limit_btn_unlimited": "♾ Make unlimited",
|
||||||
|
"admin_hwid_limit_btn_reset": "↩️ Use tariff/default",
|
||||||
|
"admin_hwid_limit_prompt": "Enter the HWID device limit for user {user_id}. 0 means unlimited:",
|
||||||
|
"admin_hwid_limit_invalid": "❌ Enter an integer device count from 0 to 1,000,000.",
|
||||||
|
"admin_hwid_limit_no_subscription": "User has no active subscription",
|
||||||
|
"admin_hwid_limit_state_missing": "⚠️ Target user missing in state. Open the user card again.",
|
||||||
|
"admin_hwid_limit_set": "✅ HWID device limit for user {user_id}: {current}",
|
||||||
|
"admin_hwid_limit_saved": "✅ HWID device limit saved",
|
||||||
|
"admin_hwid_limit_save_error": "❌ Failed to save HWID device limit",
|
||||||
"admin_user_traffic_grant_button": "🎁 Grant GB",
|
"admin_user_traffic_grant_button": "🎁 Grant GB",
|
||||||
"admin_traffic_grant_title": "🎁 Grant traffic",
|
"admin_traffic_grant_title": "🎁 Grant traffic",
|
||||||
"admin_traffic_grant_hint": "Credit GB to the user's balance — same effect as a top-up purchase, but without payment. The panel limit and squads refresh immediately.",
|
"admin_traffic_grant_hint": "Credit GB to the user's balance — same effect as a top-up purchase, but without payment. The panel limit and squads refresh immediately.",
|
||||||
@@ -1313,7 +1332,21 @@
|
|||||||
"admin_user_label_main_traffic": "Main Traffic",
|
"admin_user_label_main_traffic": "Main Traffic",
|
||||||
"admin_user_traffic_left": "Left: {left}",
|
"admin_user_traffic_left": "Left: {left}",
|
||||||
"admin_user_label_premium_squads": "Premium Squads",
|
"admin_user_label_premium_squads": "Premium Squads",
|
||||||
|
"user_label_hwid_devices": "HWID devices",
|
||||||
"user_override_unlimited_short": "Unlimited",
|
"user_override_unlimited_short": "Unlimited",
|
||||||
|
"user_hwid_limit_card_title": "HWID devices",
|
||||||
|
"user_hwid_limit_card_hint": "Manual device limit for this user. Empty value returns to the tariff or default limit.",
|
||||||
|
"user_hwid_limit_input": "Device limit",
|
||||||
|
"user_hwid_limit_input_hint": "Empty means tariff/default; 0 or the checkbox means unlimited.",
|
||||||
|
"user_hwid_limit_default_placeholder": "Tariff",
|
||||||
|
"user_hwid_limit_save": "Save",
|
||||||
|
"user_hwid_limit_status": "Current: {current}",
|
||||||
|
"user_hwid_limit_default": "Tariff / default",
|
||||||
|
"user_hwid_limit_unlimited": "Unlimited",
|
||||||
|
"user_hwid_limit_count": "{count} devices",
|
||||||
|
"user_hwid_limit_with_extra": "{total} devices ({base} + {extra})",
|
||||||
|
"hwid_limit_saved": "Device limit saved",
|
||||||
|
"hwid_limit_invalid": "Enter an integer device count from 0 to 1,000,000 or enable unlimited",
|
||||||
"user_premium_override_card_title": "Premium traffic",
|
"user_premium_override_card_title": "Premium traffic",
|
||||||
"user_premium_override_card_hint": "Unlimited access and extra volume for premium squads on top of the tariff.",
|
"user_premium_override_card_hint": "Unlimited access and extra volume for premium squads on top of the tariff.",
|
||||||
"user_regular_override_card_title": "Main traffic",
|
"user_regular_override_card_title": "Main traffic",
|
||||||
|
|||||||
@@ -284,6 +284,8 @@
|
|||||||
"admin_user_premium_override_label": "🌟 Премиум-оверрайд:",
|
"admin_user_premium_override_label": "🌟 Премиум-оверрайд:",
|
||||||
"admin_user_premium_override_unlimited": "Безлимит",
|
"admin_user_premium_override_unlimited": "Безлимит",
|
||||||
"admin_user_premium_override_bonus_value": "+{gb} GB",
|
"admin_user_premium_override_bonus_value": "+{gb} GB",
|
||||||
|
"admin_user_hwid_limit_button": "📱 HWID лимит",
|
||||||
|
"admin_user_hwid_limit_label": "📱 HWID-устройства:",
|
||||||
"admin_premium_override_title": "🌟 Премиум-оверрайд",
|
"admin_premium_override_title": "🌟 Премиум-оверрайд",
|
||||||
"admin_premium_override_hint": "Бесплатно увеличьте лимит премиум-трафика или сделайте его безлимитным для этого пользователя.",
|
"admin_premium_override_hint": "Бесплатно увеличьте лимит премиум-трафика или сделайте его безлимитным для этого пользователя.",
|
||||||
"admin_premium_override_current": "Сейчас: {current}",
|
"admin_premium_override_current": "Сейчас: {current}",
|
||||||
@@ -300,6 +302,23 @@
|
|||||||
"admin_premium_override_bonus_set": "✅ Премиум-оверрайд: +{gb} GB для пользователя {user_id}",
|
"admin_premium_override_bonus_set": "✅ Премиум-оверрайд: +{gb} GB для пользователя {user_id}",
|
||||||
"admin_premium_override_saved": "✅ Премиум-оверрайд сохранён",
|
"admin_premium_override_saved": "✅ Премиум-оверрайд сохранён",
|
||||||
"admin_premium_override_save_error": "❌ Не удалось сохранить премиум-оверрайд",
|
"admin_premium_override_save_error": "❌ Не удалось сохранить премиум-оверрайд",
|
||||||
|
"admin_hwid_limit_title": "📱 Лимит HWID-устройств",
|
||||||
|
"admin_hwid_limit_hint": "Задайте ручной базовый лимит устройств для пользователя. 0 означает безлимит; сброс вернёт тарифный или default-лимит.",
|
||||||
|
"admin_hwid_limit_current": "Сейчас: {current}",
|
||||||
|
"admin_hwid_limit_state_default": "тарифный / default",
|
||||||
|
"admin_hwid_limit_state_unlimited": "безлимит",
|
||||||
|
"admin_hwid_limit_state_count": "{count} устройств",
|
||||||
|
"admin_hwid_limit_state_with_extra": "{total} устройств ({base} + {extra})",
|
||||||
|
"admin_hwid_limit_btn_set_number": "🔢 Задать число",
|
||||||
|
"admin_hwid_limit_btn_unlimited": "♾ Включить безлимит",
|
||||||
|
"admin_hwid_limit_btn_reset": "↩️ Вернуть тариф/default",
|
||||||
|
"admin_hwid_limit_prompt": "Введите лимит HWID-устройств для пользователя {user_id}. 0 означает безлимит:",
|
||||||
|
"admin_hwid_limit_invalid": "❌ Введите целое число устройств от 0 до 1 000 000.",
|
||||||
|
"admin_hwid_limit_no_subscription": "У пользователя нет активной подписки",
|
||||||
|
"admin_hwid_limit_state_missing": "⚠️ Целевой пользователь не найден в состоянии. Откройте карточку пользователя заново.",
|
||||||
|
"admin_hwid_limit_set": "✅ Лимит HWID-устройств для пользователя {user_id}: {current}",
|
||||||
|
"admin_hwid_limit_saved": "✅ Лимит HWID-устройств сохранён",
|
||||||
|
"admin_hwid_limit_save_error": "❌ Не удалось сохранить лимит HWID-устройств",
|
||||||
"admin_user_traffic_grant_button": "🎁 Выдать ГБ",
|
"admin_user_traffic_grant_button": "🎁 Выдать ГБ",
|
||||||
"admin_traffic_grant_title": "🎁 Выдать трафик",
|
"admin_traffic_grant_title": "🎁 Выдать трафик",
|
||||||
"admin_traffic_grant_hint": "Зачисление ГБ на баланс пользователя — как при докупке, но без оплаты. Лимит и сквады в панели обновятся сразу.",
|
"admin_traffic_grant_hint": "Зачисление ГБ на баланс пользователя — как при докупке, но без оплаты. Лимит и сквады в панели обновятся сразу.",
|
||||||
@@ -1313,7 +1332,21 @@
|
|||||||
"admin_user_label_main_traffic": "Основной трафик",
|
"admin_user_label_main_traffic": "Основной трафик",
|
||||||
"admin_user_traffic_left": "Осталось: {left}",
|
"admin_user_traffic_left": "Осталось: {left}",
|
||||||
"admin_user_label_premium_squads": "Premium-сквады",
|
"admin_user_label_premium_squads": "Premium-сквады",
|
||||||
|
"user_label_hwid_devices": "HWID-устройства",
|
||||||
"user_override_unlimited_short": "Безлимит",
|
"user_override_unlimited_short": "Безлимит",
|
||||||
|
"user_hwid_limit_card_title": "HWID-устройства",
|
||||||
|
"user_hwid_limit_card_hint": "Ручной лимит устройств для пользователя. Пустое поле вернёт тарифный или default-лимит.",
|
||||||
|
"user_hwid_limit_input": "Лимит устройств",
|
||||||
|
"user_hwid_limit_input_hint": "Пусто — тариф/default; 0 или галочка — безлимит.",
|
||||||
|
"user_hwid_limit_default_placeholder": "Тариф",
|
||||||
|
"user_hwid_limit_save": "Сохранить",
|
||||||
|
"user_hwid_limit_status": "Сейчас: {current}",
|
||||||
|
"user_hwid_limit_default": "Тарифный / default",
|
||||||
|
"user_hwid_limit_unlimited": "Безлимит",
|
||||||
|
"user_hwid_limit_count": "{count} устройств",
|
||||||
|
"user_hwid_limit_with_extra": "{total} устройств ({base} + {extra})",
|
||||||
|
"hwid_limit_saved": "Лимит устройств сохранён",
|
||||||
|
"hwid_limit_invalid": "Введите целое число устройств от 0 до 1 000 000 или включите безлимит",
|
||||||
"user_premium_override_card_title": "Премиум-трафик",
|
"user_premium_override_card_title": "Премиум-трафик",
|
||||||
"user_premium_override_card_hint": "Безлимит и дополнительный объём для премиум-сквадов поверх тарифа.",
|
"user_premium_override_card_hint": "Безлимит и дополнительный объём для премиум-сквадов поверх тарифа.",
|
||||||
"user_regular_override_card_title": "Основной трафик",
|
"user_regular_override_card_title": "Основной трафик",
|
||||||
|
|||||||
@@ -50,6 +50,149 @@ def _make_settings(payload: dict, tmpdir: str) -> Settings:
|
|||||||
|
|
||||||
|
|
||||||
class AdminGrantTopupTests(unittest.IsolatedAsyncioTestCase):
|
class AdminGrantTopupTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_hwid_limit_sync_pushes_effective_device_limit(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
settings = _make_settings(_tariffs_config_payload(), tmpdir)
|
||||||
|
panel_service = AsyncMock(spec=PanelApiService)
|
||||||
|
panel_service.update_user_details_on_panel = AsyncMock(return_value={"response": {}})
|
||||||
|
service = SubscriptionService(settings, panel_service)
|
||||||
|
|
||||||
|
db_user = SimpleNamespace(
|
||||||
|
user_id=42,
|
||||||
|
first_name="Tester",
|
||||||
|
last_name=None,
|
||||||
|
username="tester",
|
||||||
|
language_code="ru",
|
||||||
|
panel_user_uuid="panel-uuid",
|
||||||
|
email=None,
|
||||||
|
telegram_id=42,
|
||||||
|
)
|
||||||
|
sub = SimpleNamespace(
|
||||||
|
subscription_id=7,
|
||||||
|
user_id=42,
|
||||||
|
panel_user_uuid="panel-uuid",
|
||||||
|
end_date=datetime.now(timezone.utc) + timedelta(days=10),
|
||||||
|
tariff_key="standard",
|
||||||
|
hwid_device_limit=4,
|
||||||
|
extra_hwid_devices=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"bot.services.subscription_service.user_dal.get_user_by_id",
|
||||||
|
new=AsyncMock(return_value=db_user),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"bot.services.subscription_service.subscription_dal.get_active_subscription_by_user_id",
|
||||||
|
new=AsyncMock(return_value=sub),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"bot.services.subscription_service.tariff_dal.sum_active_hwid_devices",
|
||||||
|
new=AsyncMock(return_value=2),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
effective_limit = await service.sync_hwid_device_limit_to_panel(AsyncMock(), 42)
|
||||||
|
|
||||||
|
self.assertEqual(effective_limit, 6)
|
||||||
|
self.assertEqual(sub.extra_hwid_devices, 2)
|
||||||
|
panel_service.update_user_details_on_panel.assert_awaited_once()
|
||||||
|
panel_payload = panel_service.update_user_details_on_panel.await_args.args[1]
|
||||||
|
self.assertEqual(panel_payload["hwidDeviceLimit"], 6)
|
||||||
|
|
||||||
|
async def test_hwid_limit_sync_keeps_zero_unlimited(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
settings = _make_settings(_tariffs_config_payload(), tmpdir)
|
||||||
|
panel_service = AsyncMock(spec=PanelApiService)
|
||||||
|
panel_service.update_user_details_on_panel = AsyncMock(return_value={"response": {}})
|
||||||
|
service = SubscriptionService(settings, panel_service)
|
||||||
|
|
||||||
|
db_user = SimpleNamespace(
|
||||||
|
user_id=42,
|
||||||
|
first_name="Tester",
|
||||||
|
last_name=None,
|
||||||
|
username="tester",
|
||||||
|
language_code="ru",
|
||||||
|
panel_user_uuid="panel-uuid",
|
||||||
|
email=None,
|
||||||
|
telegram_id=42,
|
||||||
|
)
|
||||||
|
sub = SimpleNamespace(
|
||||||
|
subscription_id=7,
|
||||||
|
user_id=42,
|
||||||
|
panel_user_uuid="panel-uuid",
|
||||||
|
end_date=datetime.now(timezone.utc) + timedelta(days=10),
|
||||||
|
tariff_key="standard",
|
||||||
|
hwid_device_limit=0,
|
||||||
|
extra_hwid_devices=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"bot.services.subscription_service.user_dal.get_user_by_id",
|
||||||
|
new=AsyncMock(return_value=db_user),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"bot.services.subscription_service.subscription_dal.get_active_subscription_by_user_id",
|
||||||
|
new=AsyncMock(return_value=sub),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"bot.services.subscription_service.tariff_dal.sum_active_hwid_devices",
|
||||||
|
new=AsyncMock(return_value=3),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
effective_limit = await service.sync_hwid_device_limit_to_panel(AsyncMock(), 42)
|
||||||
|
|
||||||
|
self.assertEqual(effective_limit, 0)
|
||||||
|
panel_payload = panel_service.update_user_details_on_panel.await_args.args[1]
|
||||||
|
self.assertEqual(panel_payload["hwidDeviceLimit"], 0)
|
||||||
|
|
||||||
|
async def test_hwid_limit_sync_treats_missing_default_as_unlimited(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
settings = _make_settings(_tariffs_config_payload(), tmpdir)
|
||||||
|
panel_service = AsyncMock(spec=PanelApiService)
|
||||||
|
panel_service.update_user_details_on_panel = AsyncMock(return_value={"response": {}})
|
||||||
|
service = SubscriptionService(settings, panel_service)
|
||||||
|
|
||||||
|
db_user = SimpleNamespace(
|
||||||
|
user_id=42,
|
||||||
|
first_name="Tester",
|
||||||
|
last_name=None,
|
||||||
|
username="tester",
|
||||||
|
language_code="ru",
|
||||||
|
panel_user_uuid="panel-uuid",
|
||||||
|
email=None,
|
||||||
|
telegram_id=42,
|
||||||
|
)
|
||||||
|
sub = SimpleNamespace(
|
||||||
|
subscription_id=7,
|
||||||
|
user_id=42,
|
||||||
|
panel_user_uuid="panel-uuid",
|
||||||
|
end_date=datetime.now(timezone.utc) + timedelta(days=10),
|
||||||
|
tariff_key="standard",
|
||||||
|
hwid_device_limit=None,
|
||||||
|
extra_hwid_devices=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"bot.services.subscription_service.user_dal.get_user_by_id",
|
||||||
|
new=AsyncMock(return_value=db_user),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"bot.services.subscription_service.subscription_dal.get_active_subscription_by_user_id",
|
||||||
|
new=AsyncMock(return_value=sub),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"bot.services.subscription_service.tariff_dal.sum_active_hwid_devices",
|
||||||
|
new=AsyncMock(return_value=2),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
effective_limit = await service.sync_hwid_device_limit_to_panel(AsyncMock(), 42)
|
||||||
|
|
||||||
|
self.assertEqual(effective_limit, 0)
|
||||||
|
panel_payload = panel_service.update_user_details_on_panel.await_args.args[1]
|
||||||
|
self.assertEqual(panel_payload["hwidDeviceLimit"], 0)
|
||||||
|
|
||||||
async def test_regular_grant_increases_balance_and_panel_limit(self):
|
async def test_regular_grant_increases_balance_and_panel_limit(self):
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
settings = _make_settings(_tariffs_config_payload(), tmpdir)
|
settings = _make_settings(_tariffs_config_payload(), tmpdir)
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from bot.app.web.admin_api_impl import users as admin_users
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
def __init__(self):
|
||||||
|
self.committed = False
|
||||||
|
self.rolled_back = False
|
||||||
|
self.refreshed = None
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def commit(self):
|
||||||
|
self.committed = True
|
||||||
|
|
||||||
|
async def rollback(self):
|
||||||
|
self.rolled_back = True
|
||||||
|
|
||||||
|
async def refresh(self, obj):
|
||||||
|
self.refreshed = obj
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRequest:
|
||||||
|
def __init__(self, body, session, subscription_service):
|
||||||
|
self.app = {
|
||||||
|
"settings": SimpleNamespace(),
|
||||||
|
"async_session_factory": lambda: session,
|
||||||
|
"subscription_service": subscription_service,
|
||||||
|
}
|
||||||
|
self.match_info = {"user_id": "42"}
|
||||||
|
self._body = body
|
||||||
|
|
||||||
|
async def json(self):
|
||||||
|
return self._body
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserHwidLimitRouteTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_unlimited_payload_stores_zero_and_syncs_panel(self):
|
||||||
|
session = FakeSession()
|
||||||
|
active = SimpleNamespace(hwid_device_limit=3)
|
||||||
|
subscription_service = SimpleNamespace(
|
||||||
|
sync_hwid_device_limit_to_panel=AsyncMock(return_value=0)
|
||||||
|
)
|
||||||
|
request = FakeRequest(
|
||||||
|
{"unlimited": True, "hwid_device_limit": 999}, session, subscription_service
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(admin_users, "_require_admin_user_id", return_value=100),
|
||||||
|
patch.object(
|
||||||
|
admin_users.subscription_dal,
|
||||||
|
"get_active_subscription_by_user_id",
|
||||||
|
AsyncMock(return_value=active),
|
||||||
|
),
|
||||||
|
patch.object(admin_users.message_log_dal, "create_message_log", AsyncMock()),
|
||||||
|
patch.object(admin_users, "_invalidate_after_admin_user_mutation", AsyncMock()),
|
||||||
|
patch.object(
|
||||||
|
admin_users,
|
||||||
|
"_serialize_subscription",
|
||||||
|
return_value={"hwid_device_limit": 0},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
response = await admin_users.admin_user_hwid_device_limit_route(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status, 200)
|
||||||
|
self.assertEqual(json.loads(response.text)["subscription"]["hwid_device_limit"], 0)
|
||||||
|
self.assertEqual(active.hwid_device_limit, 0)
|
||||||
|
subscription_service.sync_hwid_device_limit_to_panel.assert_awaited_once_with(session, 42)
|
||||||
|
self.assertTrue(session.committed)
|
||||||
|
self.assertEqual(session.refreshed, active)
|
||||||
|
|
||||||
|
async def test_use_default_payload_stores_null_override(self):
|
||||||
|
session = FakeSession()
|
||||||
|
active = SimpleNamespace(hwid_device_limit=5)
|
||||||
|
subscription_service = SimpleNamespace(
|
||||||
|
sync_hwid_device_limit_to_panel=AsyncMock(return_value=3)
|
||||||
|
)
|
||||||
|
request = FakeRequest({"use_default": True}, session, subscription_service)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(admin_users, "_require_admin_user_id", return_value=100),
|
||||||
|
patch.object(
|
||||||
|
admin_users.subscription_dal,
|
||||||
|
"get_active_subscription_by_user_id",
|
||||||
|
AsyncMock(return_value=active),
|
||||||
|
),
|
||||||
|
patch.object(admin_users.message_log_dal, "create_message_log", AsyncMock()),
|
||||||
|
patch.object(admin_users, "_invalidate_after_admin_user_mutation", AsyncMock()),
|
||||||
|
patch.object(
|
||||||
|
admin_users,
|
||||||
|
"_serialize_subscription",
|
||||||
|
return_value={"hwid_device_limit": None},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
response = await admin_users.admin_user_hwid_device_limit_route(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status, 200)
|
||||||
|
self.assertIsNone(json.loads(response.text)["subscription"]["hwid_device_limit"])
|
||||||
|
self.assertIsNone(active.hwid_device_limit)
|
||||||
|
subscription_service.sync_hwid_device_limit_to_panel.assert_awaited_once_with(session, 42)
|
||||||
|
|
||||||
|
async def test_negative_limit_is_rejected(self):
|
||||||
|
session = FakeSession()
|
||||||
|
subscription_service = SimpleNamespace(
|
||||||
|
sync_hwid_device_limit_to_panel=AsyncMock(return_value=None)
|
||||||
|
)
|
||||||
|
request = FakeRequest({"hwid_device_limit": -1}, session, subscription_service)
|
||||||
|
|
||||||
|
with patch.object(admin_users, "_require_admin_user_id", return_value=100):
|
||||||
|
response = await admin_users.admin_user_hwid_device_limit_route(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status, 400)
|
||||||
|
self.assertEqual(json.loads(response.text)["error"], "invalid_hwid_device_limit")
|
||||||
|
subscription_service.sync_hwid_device_limit_to_panel.assert_not_awaited()
|
||||||
|
|
||||||
|
async def test_over_max_limit_is_rejected(self):
|
||||||
|
session = FakeSession()
|
||||||
|
subscription_service = SimpleNamespace(
|
||||||
|
sync_hwid_device_limit_to_panel=AsyncMock(return_value=None)
|
||||||
|
)
|
||||||
|
request = FakeRequest({"hwid_device_limit": 1_000_001}, session, subscription_service)
|
||||||
|
|
||||||
|
with patch.object(admin_users, "_require_admin_user_id", return_value=100):
|
||||||
|
response = await admin_users.admin_user_hwid_device_limit_route(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status, 400)
|
||||||
|
self.assertEqual(json.loads(response.text)["error"], "invalid_hwid_device_limit")
|
||||||
|
subscription_service.sync_hwid_device_limit_to_panel.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -157,6 +157,16 @@ class CanTopupDevicesFlagTests(unittest.TestCase):
|
|||||||
payload = _serialize_subscription(settings, _active(max_devices=0), None, "en")
|
payload = _serialize_subscription(settings, _active(max_devices=0), None, "en")
|
||||||
self.assertFalse(payload["can_topup_devices"])
|
self.assertFalse(payload["can_topup_devices"])
|
||||||
|
|
||||||
|
def test_flag_is_false_when_max_devices_is_missing(self):
|
||||||
|
# Missing device limit is unlimited for Remnawave HWID limits.
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
settings = _make_settings(
|
||||||
|
tmpdir,
|
||||||
|
_tariffs_payload(hwid_rub=[{"count": 1, "price": 50}]),
|
||||||
|
)
|
||||||
|
payload = _serialize_subscription(settings, _active(max_devices=None), None, "en")
|
||||||
|
self.assertFalse(payload["can_topup_devices"])
|
||||||
|
|
||||||
def test_flag_is_false_when_tariff_has_no_hwid_packages(self):
|
def test_flag_is_false_when_tariff_has_no_hwid_packages(self):
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
settings = _make_settings(tmpdir, _tariffs_payload())
|
settings = _make_settings(tmpdir, _tariffs_payload())
|
||||||
|
|||||||
@@ -306,7 +306,9 @@ def test_admin_locale_keys_are_split_into_smaller_internal_groups():
|
|||||||
"inline_financial_description": "admin_dashboard",
|
"inline_financial_description": "admin_dashboard",
|
||||||
"inline_system_stats_message": "admin_dashboard",
|
"inline_system_stats_message": "admin_dashboard",
|
||||||
"admin_user_card_title": "admin_users",
|
"admin_user_card_title": "admin_users",
|
||||||
|
"admin_hwid_limit_title": "admin_users",
|
||||||
"user_card_open_profile_button": "admin_users",
|
"user_card_open_profile_button": "admin_users",
|
||||||
|
"user_hwid_limit_card_title": "admin_users",
|
||||||
"user_premium_override_card_title": "admin_users",
|
"user_premium_override_card_title": "admin_users",
|
||||||
"traffic_grant_regular_done": "admin_users",
|
"traffic_grant_regular_done": "admin_users",
|
||||||
"admin_payment_detail_title": "admin_payments",
|
"admin_payment_detail_title": "admin_payments",
|
||||||
|
|||||||
@@ -173,6 +173,10 @@ class WebAppRouteContractTests(unittest.TestCase):
|
|||||||
"POST",
|
"POST",
|
||||||
"/api/admin/users/{user_id}/regular-traffic-override",
|
"/api/admin/users/{user_id}/regular-traffic-override",
|
||||||
): "admin_user_regular_traffic_override_route",
|
): "admin_user_regular_traffic_override_route",
|
||||||
|
(
|
||||||
|
"POST",
|
||||||
|
"/api/admin/users/{user_id}/hwid-device-limit",
|
||||||
|
): "admin_user_hwid_device_limit_route",
|
||||||
("POST", "/api/admin/users/{user_id}/traffic-grant"): "admin_user_traffic_grant_route",
|
("POST", "/api/admin/users/{user_id}/traffic-grant"): "admin_user_traffic_grant_route",
|
||||||
("DELETE", "/api/admin/users/{user_id}"): "admin_user_delete_route",
|
("DELETE", "/api/admin/users/{user_id}"): "admin_user_delete_route",
|
||||||
("GET", "/api/admin/payments"): "admin_payments_list_route",
|
("GET", "/api/admin/payments"): "admin_payments_list_route",
|
||||||
|
|||||||
Reference in New Issue
Block a user