mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-21 11:51:06 +00:00
@@ -252,8 +252,13 @@ YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
|
||||
# Отключить отображение кнопок выбора суммы пополнения (оставить только ввод вручную)
|
||||
DISABLE_TOPUP_BUTTONS=false
|
||||
|
||||
# Автоматическая проверка зависших пополнений и повторные обращения к провайдерам
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED=false
|
||||
# Интервал (в минутах) между автоматическими проверками пополнений
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES=10
|
||||
|
||||
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
|
||||
# Эти настройки позволяют изменить описания платежей,
|
||||
# Эти настройки позволяют изменить описания платежей,
|
||||
# чтобы избежать блокировок платежных систем
|
||||
PAYMENT_SERVICE_NAME=Интернет-сервис
|
||||
PAYMENT_BALANCE_DESCRIPTION=Пополнение баланса
|
||||
|
||||
@@ -62,10 +62,34 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona
|
||||
.where(User.telegram_id == telegram_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
|
||||
if user and user.subscription:
|
||||
_ = user.subscription.is_active
|
||||
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_user_by_username(db: AsyncSession, username: str) -> Optional[User]:
|
||||
if not username:
|
||||
return None
|
||||
|
||||
normalized = username.lower()
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
.where(func.lower(User.username) == normalized)
|
||||
)
|
||||
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user and user.subscription:
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from typing import Optional, List, Tuple
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
@@ -10,7 +11,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.states import AdminStates
|
||||
from app.database.models import User, UserStatus, Subscription, SubscriptionStatus, TransactionType
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.crud.user import (
|
||||
get_user_by_id,
|
||||
get_user_by_telegram_id,
|
||||
get_user_by_username,
|
||||
get_referrals,
|
||||
)
|
||||
from app.database.crud.campaign import (
|
||||
get_campaign_registration_by_user,
|
||||
get_campaign_statistics,
|
||||
@@ -1489,6 +1495,395 @@ async def show_user_management(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _build_user_referrals_view(
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
user_id: int,
|
||||
limit: int = 30,
|
||||
) -> Optional[Tuple[str, InlineKeyboardMarkup]]:
|
||||
texts = get_texts(language)
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
referrals = await get_referrals(db, user_id)
|
||||
|
||||
header = texts.t(
|
||||
"ADMIN_USER_REFERRALS_TITLE",
|
||||
"🤝 <b>Рефералы пользователя</b>",
|
||||
)
|
||||
summary = texts.t(
|
||||
"ADMIN_USER_REFERRALS_SUMMARY",
|
||||
"👤 {name} (ID: <code>{telegram_id}</code>)\n👥 Всего рефералов: {count}",
|
||||
).format(
|
||||
name=user.full_name,
|
||||
telegram_id=user.telegram_id,
|
||||
count=len(referrals),
|
||||
)
|
||||
|
||||
lines: List[str] = [header, summary]
|
||||
|
||||
if referrals:
|
||||
lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_LIST_HEADER",
|
||||
"<b>Список рефералов:</b>",
|
||||
)
|
||||
)
|
||||
items = []
|
||||
for referral in referrals[:limit]:
|
||||
username_part = (
|
||||
f", @{referral.username}"
|
||||
if referral.username
|
||||
else ""
|
||||
)
|
||||
items.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_LIST_ITEM",
|
||||
"• {name} (ID: <code>{telegram_id}</code>{username_part})",
|
||||
).format(
|
||||
name=referral.full_name,
|
||||
telegram_id=referral.telegram_id,
|
||||
username_part=username_part,
|
||||
)
|
||||
)
|
||||
|
||||
lines.append("\n".join(items))
|
||||
|
||||
if len(referrals) > limit:
|
||||
remaining = len(referrals) - limit
|
||||
lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_LIST_TRUNCATED",
|
||||
"• … и ещё {count} рефералов",
|
||||
).format(count=remaining)
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_EMPTY",
|
||||
"Рефералов пока нет.",
|
||||
)
|
||||
)
|
||||
|
||||
lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_EDIT_HINT",
|
||||
"✏️ Чтобы изменить список, нажмите «✏️ Редактировать» ниже.",
|
||||
)
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_USER_REFERRALS_EDIT_BUTTON",
|
||||
"✏️ Редактировать",
|
||||
),
|
||||
callback_data=f"admin_user_referrals_edit_{user_id}",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data=f"admin_user_manage_{user_id}",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
return "\n\n".join(lines), keyboard
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_user_referrals(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
current_state = await state.get_state()
|
||||
if current_state == AdminStates.editing_user_referrals:
|
||||
data = await state.get_data()
|
||||
preserved_data = {
|
||||
key: value
|
||||
for key, value in data.items()
|
||||
if key not in {"editing_referrals_user_id", "referrals_message_id"}
|
||||
}
|
||||
await state.clear()
|
||||
if preserved_data:
|
||||
await state.update_data(**preserved_data)
|
||||
|
||||
view = await _build_user_referrals_view(db, db_user.language, user_id)
|
||||
if not view:
|
||||
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
text, keyboard = view
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_user_referrals(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
prompt = texts.t(
|
||||
"ADMIN_USER_REFERRALS_EDIT_PROMPT",
|
||||
(
|
||||
"✏️ <b>Редактирование рефералов</b>\n\n"
|
||||
"Отправьте список рефералов для пользователя <b>{name}</b> (ID: <code>{telegram_id}</code>):\n"
|
||||
"• Используйте TG ID или @username\n"
|
||||
"• Значения можно указывать через запятую, пробел или с новой строки\n"
|
||||
"• Чтобы очистить список, отправьте 0 или слово 'нет'\n\n"
|
||||
"Или нажмите кнопку ниже, чтобы отменить."
|
||||
),
|
||||
).format(
|
||||
name=user.full_name,
|
||||
telegram_id=user.telegram_id,
|
||||
)
|
||||
|
||||
await state.update_data(
|
||||
editing_referrals_user_id=user_id,
|
||||
referrals_message_id=callback.message.message_id,
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
prompt,
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data=f"admin_user_referrals_{user_id}",
|
||||
)
|
||||
]
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.editing_user_referrals)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_edit_user_referrals(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
data = await state.get_data()
|
||||
|
||||
user_id = data.get("editing_referrals_user_id")
|
||||
if not user_id:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_STATE_LOST",
|
||||
"❌ Не удалось определить пользователя. Попробуйте начать сначала.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
raw_text = message.text.strip()
|
||||
lower_text = raw_text.lower()
|
||||
clear_keywords = {"0", "нет", "none", "пусто", "clear"}
|
||||
clear_requested = lower_text in clear_keywords
|
||||
|
||||
tokens: List[str] = []
|
||||
if not clear_requested:
|
||||
parts = re.split(r"[,\n]+", raw_text)
|
||||
for part in parts:
|
||||
for token in part.split():
|
||||
cleaned = token.strip()
|
||||
if cleaned and cleaned not in tokens:
|
||||
tokens.append(cleaned)
|
||||
|
||||
found_users: List[User] = []
|
||||
not_found: List[str] = []
|
||||
skipped_self: List[str] = []
|
||||
duplicate_tokens: List[str] = []
|
||||
|
||||
seen_ids = set()
|
||||
|
||||
for token in tokens:
|
||||
normalized = token.strip()
|
||||
if not normalized:
|
||||
continue
|
||||
|
||||
if normalized.startswith("@"):
|
||||
normalized = normalized[1:]
|
||||
|
||||
user = None
|
||||
if normalized.isdigit():
|
||||
try:
|
||||
user = await get_user_by_telegram_id(db, int(normalized))
|
||||
except ValueError:
|
||||
user = None
|
||||
else:
|
||||
user = await get_user_by_username(db, normalized)
|
||||
|
||||
if not user:
|
||||
not_found.append(token)
|
||||
continue
|
||||
|
||||
if user.id == user_id:
|
||||
skipped_self.append(token)
|
||||
continue
|
||||
|
||||
if user.id in seen_ids:
|
||||
duplicate_tokens.append(token)
|
||||
continue
|
||||
|
||||
seen_ids.add(user.id)
|
||||
found_users.append(user)
|
||||
|
||||
if not found_users and not clear_requested:
|
||||
error_lines = [
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_NO_VALID",
|
||||
"❌ Не удалось найти ни одного пользователя по введённым данным.",
|
||||
)
|
||||
]
|
||||
if not_found:
|
||||
error_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_INVALID_ENTRIES",
|
||||
"Не найдены: {values}",
|
||||
).format(values=", ".join(not_found))
|
||||
)
|
||||
if skipped_self:
|
||||
error_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_SELF_SKIPPED",
|
||||
"Пропущены значения пользователя: {values}",
|
||||
).format(values=", ".join(skipped_self))
|
||||
)
|
||||
await message.answer("\n".join(error_lines))
|
||||
return
|
||||
|
||||
user_service = UserService()
|
||||
|
||||
new_referral_ids = [user.id for user in found_users] if not clear_requested else []
|
||||
|
||||
success, details = await user_service.update_user_referrals(
|
||||
db,
|
||||
user_id,
|
||||
new_referral_ids,
|
||||
db_user.id,
|
||||
)
|
||||
|
||||
if not success:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_UPDATE_ERROR",
|
||||
"❌ Не удалось обновить рефералов. Попробуйте позже.",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
response_lines = [
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_UPDATED",
|
||||
"✅ Список рефералов обновлён.",
|
||||
)
|
||||
]
|
||||
|
||||
total_referrals = details.get("total", len(new_referral_ids))
|
||||
added = details.get("added", 0)
|
||||
removed = details.get("removed", 0)
|
||||
|
||||
response_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_UPDATED_TOTAL",
|
||||
"• Текущий список: {total}",
|
||||
).format(total=total_referrals)
|
||||
)
|
||||
|
||||
if added > 0:
|
||||
response_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_UPDATED_ADDED",
|
||||
"• Добавлено: {count}",
|
||||
).format(count=added)
|
||||
)
|
||||
|
||||
if removed > 0:
|
||||
response_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_UPDATED_REMOVED",
|
||||
"• Удалено: {count}",
|
||||
).format(count=removed)
|
||||
)
|
||||
|
||||
if not_found:
|
||||
response_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_INVALID_ENTRIES",
|
||||
"Не найдены: {values}",
|
||||
).format(values=", ".join(not_found))
|
||||
)
|
||||
|
||||
if skipped_self:
|
||||
response_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_SELF_SKIPPED",
|
||||
"Пропущены значения пользователя: {values}",
|
||||
).format(values=", ".join(skipped_self))
|
||||
)
|
||||
|
||||
if duplicate_tokens:
|
||||
response_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_DUPLICATES",
|
||||
"Игнорированы дубли: {values}",
|
||||
).format(values=", ".join(duplicate_tokens))
|
||||
)
|
||||
|
||||
view = await _build_user_referrals_view(db, db_user.language, user_id)
|
||||
message_id = data.get("referrals_message_id")
|
||||
|
||||
if view and message_id:
|
||||
try:
|
||||
await message.bot.edit_message_text(
|
||||
view[0],
|
||||
chat_id=message.chat.id,
|
||||
message_id=message_id,
|
||||
reply_markup=view[1],
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
await message.answer(view[0], reply_markup=view[1])
|
||||
elif view:
|
||||
await message.answer(view[0], reply_markup=view[1])
|
||||
|
||||
await message.answer("\n".join(response_lines))
|
||||
await state.clear()
|
||||
|
||||
async def _render_user_promo_group(
|
||||
message: types.Message,
|
||||
language: str,
|
||||
@@ -4159,6 +4554,21 @@ def register_handlers(dp: Dispatcher):
|
||||
AdminStates.editing_user_balance
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_user_referrals,
|
||||
F.data.startswith("admin_user_referrals_") & ~F.data.contains("_edit")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_edit_user_referrals,
|
||||
F.data.startswith("admin_user_referrals_edit_")
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_edit_user_referrals,
|
||||
AdminStates.editing_user_referrals
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_send_user_message,
|
||||
F.data.startswith("admin_user_send_message_")
|
||||
|
||||
@@ -760,6 +760,12 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
|
||||
callback_data=f"admin_user_promo_group_{user_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_USER_REFERRALS_BUTTON", "🤝 Рефералы"),
|
||||
callback_data=f"admin_user_referrals_{user_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_USER_STATISTICS", "📊 Статистика"),
|
||||
|
||||
@@ -713,6 +713,26 @@
|
||||
"ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Пользователь уже состоит в этой промогруппе.",
|
||||
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ К пользователю",
|
||||
"ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Промогруппа",
|
||||
"ADMIN_USER_REFERRALS_BUTTON": "🤝 Рефералы",
|
||||
"ADMIN_USER_REFERRALS_TITLE": "🤝 <b>Рефералы пользователя</b>",
|
||||
"ADMIN_USER_REFERRALS_SUMMARY": "👤 {name} (ID: <code>{telegram_id}</code>)\n👥 Всего рефералов: {count}",
|
||||
"ADMIN_USER_REFERRALS_LIST_HEADER": "<b>Список рефералов:</b>",
|
||||
"ADMIN_USER_REFERRALS_LIST_ITEM": "• {name} (ID: <code>{telegram_id}</code>{username_part})",
|
||||
"ADMIN_USER_REFERRALS_LIST_TRUNCATED": "• … и ещё {count} рефералов",
|
||||
"ADMIN_USER_REFERRALS_EMPTY": "Рефералов пока нет.",
|
||||
"ADMIN_USER_REFERRALS_EDIT_HINT": "✏️ Чтобы изменить список, нажмите «✏️ Редактировать» ниже.",
|
||||
"ADMIN_USER_REFERRALS_EDIT_BUTTON": "✏️ Редактировать",
|
||||
"ADMIN_USER_REFERRALS_EDIT_PROMPT": "✏️ <b>Редактирование рефералов</b>\n\nОтправьте список рефералов для пользователя <b>{name}</b> (ID: <code>{telegram_id}</code>):\n• Используйте TG ID или @username\n• Значения можно указывать через запятую, пробел или с новой строки\n• Чтобы очистить список, отправьте 0 или слово 'нет'\n\nИли нажмите кнопку ниже, чтобы отменить.",
|
||||
"ADMIN_USER_REFERRALS_STATE_LOST": "❌ Не удалось определить пользователя. Попробуйте начать сначала.",
|
||||
"ADMIN_USER_REFERRALS_NO_VALID": "❌ Не удалось найти ни одного пользователя по введённым данным.",
|
||||
"ADMIN_USER_REFERRALS_INVALID_ENTRIES": "Не найдены: {values}",
|
||||
"ADMIN_USER_REFERRALS_SELF_SKIPPED": "Пропущены значения пользователя: {values}",
|
||||
"ADMIN_USER_REFERRALS_DUPLICATES": "Игнорированы дубли: {values}",
|
||||
"ADMIN_USER_REFERRALS_UPDATE_ERROR": "❌ Не удалось обновить рефералов. Попробуйте позже.",
|
||||
"ADMIN_USER_REFERRALS_UPDATED": "✅ Список рефералов обновлён.",
|
||||
"ADMIN_USER_REFERRALS_UPDATED_TOTAL": "• Текущий список: {total}",
|
||||
"ADMIN_USER_REFERRALS_UPDATED_ADDED": "• Добавлено: {count}",
|
||||
"ADMIN_USER_REFERRALS_UPDATED_REMOVED": "• Удалено: {count}",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT": "Текущая группа: {name}",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Текущая группа: не назначена",
|
||||
"ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
|
||||
|
||||
@@ -77,6 +77,20 @@ class AdminNotificationService:
|
||||
)
|
||||
return None
|
||||
|
||||
def _get_user_display(self, user: User) -> str:
|
||||
first_name = getattr(user, "first_name", "") or ""
|
||||
if first_name:
|
||||
return first_name
|
||||
|
||||
username = getattr(user, "username", "") or ""
|
||||
if username:
|
||||
return username
|
||||
|
||||
telegram_id = getattr(user, "telegram_id", None)
|
||||
if telegram_id is None:
|
||||
return "IDUnknown"
|
||||
return f"ID{telegram_id}"
|
||||
|
||||
def _format_promo_group_discounts(self, promo_group: PromoGroup) -> List[str]:
|
||||
discount_lines: List[str] = []
|
||||
|
||||
@@ -185,6 +199,7 @@ class AdminNotificationService:
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
trial_device_limit = subscription.device_limit
|
||||
if trial_device_limit is None:
|
||||
@@ -196,7 +211,7 @@ class AdminNotificationService:
|
||||
|
||||
message = f"""🎯 <b>АКТИВАЦИЯ ТРИАЛА</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {getattr(user, 'first_name', '') or getattr(user, 'username', '') or f"ID{getattr(user, 'telegram_id', 'Unknown')}"}
|
||||
👤 <b>Пользователь:</b> {user_display}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
|
||||
👥 <b>Статус:</b> {user_status}
|
||||
@@ -235,26 +250,27 @@ class AdminNotificationService:
|
||||
|
||||
try:
|
||||
event_type = "🔄 КОНВЕРСИЯ ИЗ ТРИАЛА" if was_trial_conversion else "💎 ПОКУПКА ПОДПИСКИ"
|
||||
|
||||
|
||||
if was_trial_conversion:
|
||||
user_status = "🎯 Конверсия из триала"
|
||||
elif user.has_had_paid_subscription:
|
||||
user_status = "🔄 Продление/Обновление"
|
||||
else:
|
||||
user_status = "🆕 Первая покупка"
|
||||
|
||||
|
||||
servers_info = await self._get_servers_info(subscription.connected_squads)
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method) if transaction else "Баланс"
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
total_amount = amount_kopeks if amount_kopeks is not None else (transaction.amount_kopeks if transaction else 0)
|
||||
transaction_id = transaction.id if transaction else "—"
|
||||
|
||||
message = f"""💎 <b>{event_type}</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {getattr(user, 'first_name', '') or getattr(user, 'username', '') or f"ID{getattr(user, 'telegram_id', 'Unknown')}"}
|
||||
👤 <b>Пользователь:</b> {user_display}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
|
||||
👥 <b>Статус:</b> {user_status}
|
||||
@@ -372,10 +388,11 @@ class AdminNotificationService:
|
||||
subscription_status = self._get_subscription_status(subscription)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
timestamp = datetime.now().strftime('%d.%m.%Y %H:%M:%S')
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
return f"""💰 <b>ПОПОЛНЕНИЕ БАЛАНСА</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {getattr(user, 'first_name', '') or getattr(user, 'username', '') or f"ID{getattr(user, 'telegram_id', 'Unknown')}"}
|
||||
👤 <b>Пользователь:</b> {user_display}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
|
||||
💳 <b>Статус:</b> {topup_status}
|
||||
@@ -548,13 +565,14 @@ class AdminNotificationService:
|
||||
servers_info = await self._get_servers_info(subscription.connected_squads)
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
current_end_date = new_end_date or subscription.end_date
|
||||
current_balance = balance_after if balance_after is not None else user.balance_kopeks
|
||||
|
||||
message = f"""⏰ <b>ПРОДЛЕНИЕ ПОДПИСКИ</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {getattr(user, 'first_name', '') or getattr(user, 'username', '') or f"ID{getattr(user, 'telegram_id', 'Unknown')}"}
|
||||
👤 <b>Пользователь:</b> {user_display}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
|
||||
|
||||
@@ -600,11 +618,12 @@ class AdminNotificationService:
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
type_display = self._get_promocode_type_display(promocode_data.get("type"))
|
||||
usage_info = f"{promocode_data.get('current_uses', 0)}/{promocode_data.get('max_uses', 0)}"
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
message_lines = [
|
||||
"🎫 <b>АКТИВАЦИЯ ПРОМОКОДА</b>",
|
||||
"",
|
||||
f"👤 <b>Пользователь:</b> {getattr(user, 'first_name', '') or getattr(user, 'username', '') or f"ID{getattr(user, 'telegram_id', 'Unknown')}"}",
|
||||
f"👤 <b>Пользователь:</b> {user_display}",
|
||||
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
|
||||
f"📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}",
|
||||
"",
|
||||
@@ -727,11 +746,12 @@ class AdminNotificationService:
|
||||
)
|
||||
elif automatic:
|
||||
initiator_line = "🤖 Автоматическое назначение"
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
message_lines = [
|
||||
f"{title}",
|
||||
"",
|
||||
f"👤 <b>Пользователь:</b> {getattr(user, 'first_name', '') or getattr(user, 'username', '') or f"ID{getattr(user, 'telegram_id', 'Unknown')}"}",
|
||||
f"👤 <b>Пользователь:</b> {user_display}",
|
||||
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
|
||||
f"📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}",
|
||||
"",
|
||||
@@ -1103,6 +1123,7 @@ class AdminNotificationService:
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
update_types = {
|
||||
"traffic": ("📊 ИЗМЕНЕНИЕ ТРАФИКА", "трафик"),
|
||||
@@ -1115,7 +1136,7 @@ class AdminNotificationService:
|
||||
message_lines = [
|
||||
f"{title}",
|
||||
"",
|
||||
f"👤 <b>Пользователь:</b> {getattr(user, 'first_name', '') or getattr(user, 'username', '') or f"ID{getattr(user, 'telegram_id', 'Unknown')}"}",
|
||||
f"👤 <b>Пользователь:</b> {user_display}",
|
||||
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
|
||||
f"📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}",
|
||||
"",
|
||||
|
||||
@@ -89,16 +89,64 @@ async def process_referral_topup(
|
||||
logger.info(f"Пользователь {user_id} не является рефералом")
|
||||
return True
|
||||
|
||||
if topup_amount_kopeks < settings.REFERRAL_MINIMUM_TOPUP_KOPEKS:
|
||||
logger.info(f"Пополнение {user_id} на {topup_amount_kopeks/100}₽ меньше минимума")
|
||||
return True
|
||||
|
||||
referrer = await get_user_by_id(db, user.referred_by_id)
|
||||
if not referrer:
|
||||
logger.error(f"Реферер {user.referred_by_id} не найден")
|
||||
return False
|
||||
|
||||
|
||||
qualifies_for_first_bonus = (
|
||||
topup_amount_kopeks >= settings.REFERRAL_MINIMUM_TOPUP_KOPEKS
|
||||
)
|
||||
commission_amount = 0
|
||||
if settings.REFERRAL_COMMISSION_PERCENT > 0:
|
||||
commission_amount = int(
|
||||
topup_amount_kopeks * settings.REFERRAL_COMMISSION_PERCENT / 100
|
||||
)
|
||||
|
||||
if not user.has_made_first_topup:
|
||||
if not qualifies_for_first_bonus:
|
||||
logger.info(
|
||||
"Пополнение %s на %s₽ меньше минимума для первого бонуса, но комиссия будет начислена",
|
||||
user_id,
|
||||
topup_amount_kopeks / 100,
|
||||
)
|
||||
|
||||
if commission_amount > 0:
|
||||
await add_user_balance(
|
||||
db,
|
||||
referrer,
|
||||
commission_amount,
|
||||
f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с пополнения {user.full_name}",
|
||||
bot=bot,
|
||||
)
|
||||
|
||||
await create_referral_earning(
|
||||
db=db,
|
||||
user_id=referrer.id,
|
||||
referral_id=user.id,
|
||||
amount_kopeks=commission_amount,
|
||||
reason="referral_commission_topup",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"💰 Комиссия с пополнения: %s получил %s₽ (до первого бонуса)",
|
||||
referrer.telegram_id,
|
||||
commission_amount / 100,
|
||||
)
|
||||
|
||||
if bot:
|
||||
commission_notification = (
|
||||
f"💰 <b>Реферальная комиссия!</b>\n\n"
|
||||
f"Ваш реферал <b>{user.full_name}</b> пополнил баланс на "
|
||||
f"{settings.format_price(topup_amount_kopeks)}\n\n"
|
||||
f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): "
|
||||
f"{settings.format_price(commission_amount)}\n\n"
|
||||
f"💎 Средства зачислены на ваш баланс."
|
||||
)
|
||||
await send_referral_notification(bot, referrer.telegram_id, commission_notification)
|
||||
|
||||
return True
|
||||
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
@@ -161,36 +209,33 @@ async def process_referral_topup(
|
||||
await send_referral_notification(bot, referrer.telegram_id, inviter_bonus_notification)
|
||||
|
||||
else:
|
||||
if settings.REFERRAL_COMMISSION_PERCENT > 0:
|
||||
commission_amount = int(topup_amount_kopeks * settings.REFERRAL_COMMISSION_PERCENT / 100)
|
||||
|
||||
if commission_amount > 0:
|
||||
await add_user_balance(
|
||||
db, referrer, commission_amount,
|
||||
f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с пополнения {user.full_name}",
|
||||
bot=bot
|
||||
if commission_amount > 0:
|
||||
await add_user_balance(
|
||||
db, referrer, commission_amount,
|
||||
f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с пополнения {user.full_name}",
|
||||
bot=bot
|
||||
)
|
||||
|
||||
await create_referral_earning(
|
||||
db=db,
|
||||
user_id=referrer.id,
|
||||
referral_id=user.id,
|
||||
amount_kopeks=commission_amount,
|
||||
reason="referral_commission_topup"
|
||||
)
|
||||
|
||||
logger.info(f"💰 Комиссия с пополнения: {referrer.telegram_id} получил {commission_amount/100}₽")
|
||||
|
||||
if bot:
|
||||
commission_notification = (
|
||||
f"💰 <b>Реферальная комиссия!</b>\n\n"
|
||||
f"Ваш реферал <b>{user.full_name}</b> пополнил баланс на "
|
||||
f"{settings.format_price(topup_amount_kopeks)}\n\n"
|
||||
f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): "
|
||||
f"{settings.format_price(commission_amount)}\n\n"
|
||||
f"💎 Средства зачислены на ваш баланс."
|
||||
)
|
||||
|
||||
await create_referral_earning(
|
||||
db=db,
|
||||
user_id=referrer.id,
|
||||
referral_id=user.id,
|
||||
amount_kopeks=commission_amount,
|
||||
reason="referral_commission_topup"
|
||||
)
|
||||
|
||||
logger.info(f"💰 Комиссия с пополнения: {referrer.telegram_id} получил {commission_amount/100}₽")
|
||||
|
||||
if bot:
|
||||
commission_notification = (
|
||||
f"💰 <b>Реферальная комиссия!</b>\n\n"
|
||||
f"Ваш реферал <b>{user.full_name}</b> пополнил баланс на "
|
||||
f"{settings.format_price(topup_amount_kopeks)}\n\n"
|
||||
f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): "
|
||||
f"{settings.format_price(commission_amount)}\n\n"
|
||||
f"💎 Средства зачислены на ваш баланс."
|
||||
)
|
||||
await send_referral_notification(bot, referrer.telegram_id, commission_notification)
|
||||
await send_referral_notification(bot, referrer.telegram_id, commission_notification)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from app.database.crud.user import (
|
||||
get_user_by_id, get_user_by_telegram_id, get_users_list,
|
||||
get_users_count, get_users_statistics, get_inactive_users,
|
||||
add_user_balance, subtract_user_balance, update_user, delete_user,
|
||||
get_users_spending_stats
|
||||
get_users_spending_stats, get_referrals
|
||||
)
|
||||
from app.database.crud.promo_group import get_promo_group_by_id
|
||||
from app.database.crud.transaction import get_user_transactions_count
|
||||
@@ -411,6 +411,72 @@ class UserService:
|
||||
logger.error(f"Ошибка обновления промогруппы пользователя {user_id}: {e}")
|
||||
return False, None, None, None
|
||||
|
||||
async def update_user_referrals(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
referral_user_ids: List[int],
|
||||
admin_id: int,
|
||||
) -> Tuple[bool, Dict[str, int]]:
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return False, {"error": "user_not_found"}
|
||||
|
||||
unique_ids: List[int] = []
|
||||
for referral_id in referral_user_ids:
|
||||
if referral_id == user_id:
|
||||
continue
|
||||
if referral_id not in unique_ids:
|
||||
unique_ids.append(referral_id)
|
||||
|
||||
current_referrals = await get_referrals(db, user_id)
|
||||
current_ids = {ref.id for ref in current_referrals}
|
||||
|
||||
to_assign = unique_ids
|
||||
to_remove = [rid for rid in current_ids if rid not in unique_ids]
|
||||
to_add = [rid for rid in unique_ids if rid not in current_ids]
|
||||
|
||||
if to_assign:
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id.in_(to_assign))
|
||||
.values(referred_by_id=user_id)
|
||||
)
|
||||
|
||||
if to_remove:
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id.in_(to_remove))
|
||||
.values(referred_by_id=None)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"Админ %s обновил рефералов пользователя %s: добавлено %s, удалено %s, всего %s",
|
||||
admin_id,
|
||||
user_id,
|
||||
len(to_add),
|
||||
len(to_remove),
|
||||
len(unique_ids),
|
||||
)
|
||||
|
||||
return True, {
|
||||
"added": len(to_add),
|
||||
"removed": len(to_remove),
|
||||
"total": len(unique_ids),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(
|
||||
"Ошибка обновления рефералов пользователя %s: %s",
|
||||
user_id,
|
||||
e,
|
||||
)
|
||||
return False, {"error": "update_failed"}
|
||||
|
||||
async def block_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
|
||||
@@ -90,6 +90,7 @@ class AdminStates(StatesGroup):
|
||||
editing_device_price = State()
|
||||
editing_user_devices = State()
|
||||
editing_user_traffic = State()
|
||||
editing_user_referrals = State()
|
||||
|
||||
editing_rules_page = State()
|
||||
editing_privacy_policy = State()
|
||||
|
||||
@@ -52,12 +52,12 @@ def _serialize(group: PromoGroup, members_count: int = 0) -> PromoGroupResponse:
|
||||
apply_discounts_to_addons=group.apply_discounts_to_addons,
|
||||
is_default=group.is_default,
|
||||
members_count=members_count,
|
||||
created_at=group.created_at,
|
||||
updated_at=group.updated_at,
|
||||
created_at=getattr(group, "created_at", None),
|
||||
updated_at=getattr(group, "updated_at", None),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=PromoGroupListResponse)
|
||||
@router.get("", response_model=PromoGroupListResponse, response_model_exclude_none=True)
|
||||
async def list_promo_groups(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
@@ -79,7 +79,7 @@ async def list_promo_groups(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{group_id}", response_model=PromoGroupResponse)
|
||||
@router.get("/{group_id}", response_model=PromoGroupResponse, response_model_exclude_none=True)
|
||||
async def get_promo_group(
|
||||
group_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
@@ -93,7 +93,12 @@ async def get_promo_group(
|
||||
return _serialize(group, members_count=members_count)
|
||||
|
||||
|
||||
@router.post("", response_model=PromoGroupResponse, status_code=status.HTTP_201_CREATED)
|
||||
@router.post(
|
||||
"",
|
||||
response_model=PromoGroupResponse,
|
||||
response_model_exclude_none=True,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_promo_group_endpoint(
|
||||
payload: PromoGroupCreateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
@@ -120,7 +125,11 @@ async def create_promo_group_endpoint(
|
||||
return _serialize(group, members_count=0)
|
||||
|
||||
|
||||
@router.patch("/{group_id}", response_model=PromoGroupResponse)
|
||||
@router.patch(
|
||||
"/{group_id}",
|
||||
response_model=PromoGroupResponse,
|
||||
response_model_exclude_none=True,
|
||||
)
|
||||
async def update_promo_group_endpoint(
|
||||
group_id: int,
|
||||
payload: PromoGroupUpdateRequest,
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Dict, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, validator
|
||||
|
||||
|
||||
def _normalize_period_discounts(value: Optional[Dict[object, object]]) -> Optional[Dict[int, int]]:
|
||||
@@ -23,6 +23,8 @@ def _normalize_period_discounts(value: Optional[Dict[object, object]]) -> Option
|
||||
|
||||
|
||||
class PromoGroupResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
server_discount_percent: int
|
||||
@@ -33,8 +35,8 @@ class PromoGroupResponse(BaseModel):
|
||||
apply_discounts_to_addons: bool
|
||||
is_default: bool
|
||||
members_count: int = 0
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class _PromoGroupBase(BaseModel):
|
||||
|
||||
67
tests/services/test_referral_service.py
Normal file
67
tests/services/test_referral_service.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
from app.services import referral_service # noqa: E402
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commission_accrues_before_minimum_first_topup(monkeypatch):
|
||||
user = SimpleNamespace(
|
||||
id=1,
|
||||
telegram_id=101,
|
||||
full_name="Test User",
|
||||
referred_by_id=2,
|
||||
has_made_first_topup=False,
|
||||
)
|
||||
referrer = SimpleNamespace(
|
||||
id=2,
|
||||
telegram_id=202,
|
||||
full_name="Referrer",
|
||||
)
|
||||
|
||||
db = SimpleNamespace(
|
||||
commit=AsyncMock(),
|
||||
execute=AsyncMock(),
|
||||
)
|
||||
|
||||
get_user_mock = AsyncMock(side_effect=[user, referrer])
|
||||
monkeypatch.setattr(referral_service, "get_user_by_id", get_user_mock)
|
||||
add_user_balance_mock = AsyncMock()
|
||||
monkeypatch.setattr(referral_service, "add_user_balance", add_user_balance_mock)
|
||||
create_referral_earning_mock = AsyncMock()
|
||||
monkeypatch.setattr(referral_service, "create_referral_earning", create_referral_earning_mock)
|
||||
|
||||
monkeypatch.setattr(referral_service.settings, "REFERRAL_MINIMUM_TOPUP_KOPEKS", 20000)
|
||||
monkeypatch.setattr(referral_service.settings, "REFERRAL_FIRST_TOPUP_BONUS_KOPEKS", 5000)
|
||||
monkeypatch.setattr(referral_service.settings, "REFERRAL_INVITER_BONUS_KOPEKS", 10000)
|
||||
monkeypatch.setattr(referral_service.settings, "REFERRAL_COMMISSION_PERCENT", 25)
|
||||
|
||||
topup_amount = 15000
|
||||
|
||||
result = await referral_service.process_referral_topup(db, user.id, topup_amount)
|
||||
|
||||
assert result is True
|
||||
assert user.has_made_first_topup is False
|
||||
|
||||
add_user_balance_mock.assert_awaited_once()
|
||||
add_call = add_user_balance_mock.await_args
|
||||
assert add_call.args[1] is referrer
|
||||
assert add_call.args[2] == 3750
|
||||
assert "Комиссия" in add_call.args[3]
|
||||
assert add_call.kwargs.get("bot") is None
|
||||
|
||||
create_referral_earning_mock.assert_awaited_once()
|
||||
earning_call = create_referral_earning_mock.await_args
|
||||
assert earning_call.kwargs["amount_kopeks"] == 3750
|
||||
assert earning_call.kwargs["reason"] == "referral_commission_topup"
|
||||
|
||||
db.commit.assert_not_awaited()
|
||||
db.execute.assert_not_awaited()
|
||||
Reference in New Issue
Block a user