diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py
index 589ef0a1..4e4b5b58 100644
--- a/app/handlers/admin/users.py
+++ b/app/handlers/admin/users.py
@@ -20,6 +20,7 @@ from app.keyboards.admin import (
get_admin_users_filters_keyboard, get_user_promo_group_keyboard
)
from app.localization.texts import get_texts
+from app.services.admin_notification_service import AdminNotificationService
from app.services.user_service import UserService
from app.database.crud.promo_group import get_promo_groups_with_counts
from app.utils.decorators import admin_required, error_handler
@@ -999,6 +1000,13 @@ async def set_user_promo_group(
await callback.answer(texts.ADMIN_USER_PROMO_GROUP_ALREADY, show_alert=True)
return
+ try:
+ await db.refresh(user, ["promo_group"])
+ except Exception:
+ pass
+
+ previous_group = getattr(user, "promo_group", None)
+
user_service = UserService()
success, updated_user, new_group = await user_service.update_user_promo_group(
db,
@@ -1010,6 +1018,23 @@ async def set_user_promo_group(
await callback.answer(texts.ADMIN_USER_PROMO_GROUP_ERROR, show_alert=True)
return
+ try:
+ notification_service = AdminNotificationService(callback.bot)
+ await notification_service.send_promo_group_change_notification(
+ db,
+ updated_user,
+ previous_group,
+ new_group,
+ changed_by=db_user,
+ change_source="manual",
+ )
+ except Exception as notification_error:
+ logger.error(
+ "Ошибка отправки админ-уведомления о смене промогруппы пользователя %s: %s",
+ user_id,
+ notification_error,
+ )
+
promo_groups = await get_promo_groups_with_counts(db)
await _render_user_promo_group(callback.message, db_user.language, updated_user, promo_groups)
diff --git a/app/handlers/promocode.py b/app/handlers/promocode.py
index 62b3bc8d..248926ed 100644
--- a/app/handlers/promocode.py
+++ b/app/handlers/promocode.py
@@ -8,6 +8,7 @@ from app.states import PromoCodeStates
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
+from app.services.admin_notification_service import AdminNotificationService
from app.services.promocode_service import PromoCodeService
from app.utils.decorators import error_handler
@@ -61,6 +62,23 @@ async def process_promocode(
reply_markup=get_back_keyboard(db_user.language)
)
logger.info(f"✅ Пользователь {db_user.telegram_id} активировал промокод {code}")
+
+ promocode_obj = result.get("promocode")
+ if promocode_obj:
+ try:
+ notification_service = AdminNotificationService(message.bot)
+ await notification_service.send_promocode_activation_notification(
+ db,
+ db_user,
+ promocode_obj,
+ result.get("description", ""),
+ )
+ except Exception as notification_error:
+ logger.error(
+ "Ошибка отправки админ-уведомления об активации промокода %s: %s",
+ code,
+ notification_error,
+ )
else:
error_messages = {
"not_found": texts.PROMOCODE_INVALID,
diff --git a/app/handlers/start.py b/app/handlers/start.py
index dff36a95..6a224e38 100644
--- a/app/handlers/start.py
+++ b/app/handlers/start.py
@@ -25,6 +25,7 @@ from app.localization.loader import DEFAULT_LANGUAGE
from app.localization.texts import get_texts, get_rules
from app.services.referral_service import process_referral_registration
from app.services.campaign_service import AdvertisingCampaignService
+from app.services.admin_notification_service import AdminNotificationService
from app.services.subscription_service import SubscriptionService
from app.utils.user_utils import generate_unique_referral_code
from app.database.crud.user_message import get_random_active_message
@@ -165,6 +166,22 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
await state.update_data(referral_code=referral_code)
user = db_user if db_user else await get_user_by_telegram_id(db, message.from_user.id)
+
+ if campaign:
+ try:
+ notification_service = AdminNotificationService(message.bot)
+ await notification_service.send_campaign_click_notification(
+ db,
+ campaign,
+ message.from_user,
+ user,
+ )
+ except Exception as notification_error:
+ logger.error(
+ "Ошибка отправки уведомления о переходе по кампании %s: %s",
+ campaign.id if campaign else 'unknown',
+ notification_error,
+ )
if user and user.status != UserStatus.DELETED.value:
logger.info(f"✅ Активный пользователь найден: {user.telegram_id}")
diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py
index 19fb035c..57b69176 100644
--- a/app/services/admin_notification_service.py
+++ b/app/services/admin_notification_service.py
@@ -7,7 +7,16 @@ from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
-from app.database.models import User, Subscription, Transaction, TransactionType
+from app.database.models import (
+ AdvertisingCampaign,
+ PromoCode,
+ PromoCodeType,
+ PromoGroup,
+ Subscription,
+ Transaction,
+ TransactionType,
+ User,
+)
from app.database.crud.user import get_user_by_id
logger = logging.getLogger(__name__)
@@ -48,11 +57,12 @@ class AdminNotificationService:
) -> bool:
if not self._is_enabled():
return False
-
+
try:
user_status = "🆕 Новый" if not user.has_had_paid_subscription else "🔄 Существующий"
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
-
+ promo_group_info = await self._format_user_promo_group_info(db, user)
+
message = f"""🎯 АКТИВАЦИЯ ТРИАЛА
👤 Пользователь: {user.full_name}
@@ -60,6 +70,8 @@ class AdminNotificationService:
📱 Username: @{user.username or 'отсутствует'}
👥 Статус: {user_status}
+{promo_group_info}
+
⏰ Параметры триала:
📅 Период: {settings.TRIAL_DURATION_DAYS} дней
📊 Трафик: {settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ
@@ -98,11 +110,12 @@ class AdminNotificationService:
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)
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
-
+ promo_group_info = await self._format_user_promo_group_info(db, user)
+
message = f"""💎 {event_type}
👤 Пользователь: {user.full_name}
@@ -110,6 +123,8 @@ class AdminNotificationService:
📱 Username: @{user.username or 'отсутствует'}
👥 Статус: {user_status}
+{promo_group_info}
+
💰 Платеж:
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
💳 Способ: {payment_method}
@@ -230,6 +245,7 @@ class AdminNotificationService:
payment_method = self._get_payment_method_display(transaction.payment_method)
balance_change = user.balance_kopeks - old_balance
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
+ promo_group_info = await self._format_user_promo_group_info(db, user)
subscription_result = await db.execute(
select(Subscription).where(Subscription.user_id == user.id)
)
@@ -243,6 +259,8 @@ class AdminNotificationService:
📱 Username: @{user.username or 'отсутствует'}
💳 Статус: {topup_status}
+{promo_group_info}
+
💰 Детали пополнения:
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
💳 Способ: {payment_method}
@@ -279,13 +297,16 @@ class AdminNotificationService:
try:
payment_method = self._get_payment_method_display(transaction.payment_method)
servers_info = await self._get_servers_info(subscription.connected_squads)
-
+ promo_group_info = await self._format_user_promo_group_info(db, user)
+
message = f"""⏰ ПРОДЛЕНИЕ ПОДПИСКИ
👤 Пользователь: {user.full_name}
🆔 Telegram ID: {user.telegram_id}
📱 Username: @{user.username or 'отсутствует'}
+{promo_group_info}
+
💰 Платеж:
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
💳 Способ: {payment_method}
@@ -748,21 +769,24 @@ class AdminNotificationService:
try:
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
-
+ promo_group_info = await self._format_user_promo_group_info(db, user)
+
update_types = {
"traffic": ("📊 ИЗМЕНЕНИЕ ТРАФИКА", "трафик"),
- "devices": ("📱 ИЗМЕНЕНИЕ УСТРОЙСТВ", "количество устройств"),
+ "devices": ("📱 ИЗМЕНЕНИЕ УСТРОЙСТВ", "количество устройств"),
"servers": ("🌐 ИЗМЕНЕНИЕ СЕРВЕРОВ", "серверы")
}
title, param_name = update_types.get(update_type, ("⚙️ ИЗМЕНЕНИЕ ПОДПИСКИ", "параметры"))
-
+
message = f"""{title}
👤 Пользователь: {user.full_name}
🆔 Telegram ID: {user.telegram_id}
📱 Username: @{user.username or 'отсутствует'}
+ {promo_group_info}
+
🔧 Изменение:
📋 Параметр: {param_name}"""
@@ -790,13 +814,173 @@ class AdminNotificationService:
🔗 Рефер: {referrer_info}
⏰ {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}"""
-
+
return await self._send_message(message)
-
+
except Exception as e:
logger.error(f"Ошибка отправки уведомления об изменении подписки: {e}")
return False
+ async def send_promocode_activation_notification(
+ self,
+ db: AsyncSession,
+ user: User,
+ promocode: PromoCode,
+ result_description: str
+ ) -> bool:
+ if not self._is_enabled():
+ return False
+
+ try:
+ referrer_info = await self._get_referrer_info(db, user.referred_by_id)
+ promo_group_info = await self._format_user_promo_group_info(db, user)
+
+ promocode_type_display = {
+ PromoCodeType.BALANCE.value: "💰 Баланс",
+ PromoCodeType.SUBSCRIPTION_DAYS.value: "⏰ Продление подписки",
+ PromoCodeType.TRIAL_SUBSCRIPTION.value: "🎯 Триал",
+ }.get(promocode.type, promocode.type)
+
+ uses_text = f"{promocode.current_uses}/{promocode.max_uses}" if promocode.max_uses else f"{promocode.current_uses}"
+ valid_until = (
+ promocode.valid_until.strftime('%d.%m.%Y %H:%M')
+ if promocode.valid_until
+ else "Бессрочно"
+ )
+
+ bonus_parts: List[str] = []
+ if promocode.balance_bonus_kopeks > 0:
+ bonus_parts.append(f"💰 Баланс: {settings.format_price(promocode.balance_bonus_kopeks)}")
+ if promocode.subscription_days > 0:
+ bonus_parts.append(f"⏱ Дней подписки: {promocode.subscription_days}")
+ if not bonus_parts:
+ bonus_parts.append("ℹ️ Дополнительных бонусов нет")
+
+ message = f"""🎫 АКТИВАЦИЯ ПРОМОКОДА
+
+👤 Пользователь: {user.full_name}
+🆔 Telegram ID: {user.telegram_id}
+📱 Username: @{user.username or 'отсутствует'}
+
+{promo_group_info}
+
+🔗 Реферер: {referrer_info}
+
+🎟 Промокод: {promocode.code}
+🏷 Тип: {promocode_type_display}
+📊 Использования: {uses_text}
+⏳ Действует до: {valid_until}
+🎁 Бонусы: {'; '.join(bonus_parts)}
+
+📝 Результат: {result_description or 'Успешная активация'}
+
+⏰ {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}"""
+
+ return await self._send_message(message)
+
+ except Exception as e:
+ logger.error(f"Ошибка отправки уведомления об активации промокода: {e}")
+ return False
+
+ async def send_campaign_click_notification(
+ self,
+ db: AsyncSession,
+ campaign: AdvertisingCampaign,
+ telegram_user: types.User,
+ existing_user: Optional[User] = None
+ ) -> bool:
+ if not self._is_enabled():
+ return False
+
+ try:
+ promo_group_info = await self._format_user_promo_group_info(db, existing_user)
+
+ if existing_user:
+ referrer_info = await self._get_referrer_info(db, existing_user.referred_by_id)
+ user_state = "✅ Зарегистрирован"
+ else:
+ referrer_info = "—"
+ user_state = "🆕 Новый (не в базе)"
+
+ username_display = f"@{telegram_user.username}" if telegram_user.username else "отсутствует"
+
+ message = f"""📣 ПЕРЕХОД ПО РЕКЛАМНОЙ КАМПАНИИ
+
+🔗 Кампания: {campaign.name} (ID: {campaign.id})
+🧷 Старт-параметр: {campaign.start_parameter}
+
+👤 Пользователь: {telegram_user.full_name or '—'}
+🆔 Telegram ID: {telegram_user.id}
+📱 Username: {username_display}
+📊 Статус: {user_state}
+
+{promo_group_info}
+
+🔗 Реферер: {referrer_info}
+
+⏰ {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}"""
+
+ return await self._send_message(message)
+
+ except Exception as e:
+ logger.error(f"Ошибка отправки уведомления о переходе по кампании: {e}")
+ return False
+
+ async def send_promo_group_change_notification(
+ self,
+ db: AsyncSession,
+ user: User,
+ old_group: Optional[PromoGroup],
+ new_group: PromoGroup,
+ *,
+ changed_by: Optional[User] = None,
+ change_source: Optional[str] = None
+ ) -> bool:
+ if not self._is_enabled():
+ return False
+
+ try:
+ promo_group_info = await self._format_user_promo_group_info(db, user)
+ referrer_info = await self._get_referrer_info(db, user.referred_by_id)
+
+ old_summary = self._format_promo_group_summary(old_group)
+ new_summary = self._format_promo_group_summary(new_group)
+
+ if changed_by:
+ changer_line = (
+ f"👨💼 Инициатор: {changed_by.full_name}"
+ f" (ID: {changed_by.telegram_id})"
+ )
+ elif change_source == "auto":
+ changer_line = "🤖 Инициатор: Автоматическое назначение"
+ else:
+ changer_line = "ℹ️ Инициатор: Не указан"
+
+ message = f"""🏷️ СМЕНА ПРОМОГРУППЫ
+
+👤 Пользователь: {user.full_name}
+🆔 Telegram ID: {user.telegram_id}
+📱 Username: @{user.username or 'отсутствует'}
+
+{promo_group_info}
+
+🔄 Изменение:
+🔻 Была:
+{old_summary}
+🔺 Стала:
+{new_summary}
+
+{changer_line}
+🔗 Реферер: {referrer_info}
+
+⏰ {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}"""
+
+ return await self._send_message(message)
+
+ except Exception as e:
+ logger.error(f"Ошибка отправки уведомления о смене промогруппы: {e}")
+ return False
+
async def _format_servers_detailed(self, server_uuids: List[str]) -> str:
if not server_uuids:
return "Нет серверов"
@@ -827,6 +1011,65 @@ class AdminNotificationService:
return str(value)
return str(value)
+ async def _format_user_promo_group_info(
+ self,
+ db: AsyncSession,
+ user: Optional[User]
+ ) -> str:
+ if not user:
+ return "🏷️ Промогруппа: не назначена\n💸 Скидки: недоступны"
+
+ try:
+ await db.refresh(user, ["promo_group"])
+ except Exception:
+ pass
+
+ promo_group: Optional[PromoGroup] = getattr(user, "promo_group", None)
+ if not promo_group:
+ return "🏷️ Промогруппа: не назначена\n💸 Скидки: недоступны"
+
+ discount_lines = self._build_promo_group_discount_lines(promo_group)
+
+ return "\n".join([
+ f"🏷️ Промогруппа: {promo_group.name}",
+ "💸 Скидки:",
+ *discount_lines,
+ ])
+
+ def _format_promo_group_summary(self, promo_group: Optional[PromoGroup]) -> str:
+ if not promo_group:
+ return "— Не назначена"
+
+ lines = [f"{promo_group.name}"]
+ discount_lines = self._build_promo_group_discount_lines(promo_group)
+ lines.extend(f" {line}" for line in discount_lines)
+ return "\n".join(lines)
+
+ def _build_promo_group_discount_lines(self, promo_group: PromoGroup) -> List[str]:
+ period_text = self._format_period_discounts(promo_group)
+ return [
+ f"• Серверы: {promo_group.server_discount_percent}%",
+ f"• Трафик: {promo_group.traffic_discount_percent}%",
+ f"• Устройства: {promo_group.device_discount_percent}%",
+ f"• Периоды: {period_text}",
+ f"• Доп. услуги: {'Да' if promo_group.apply_discounts_to_addons else 'Нет'}",
+ ]
+
+ def _format_period_discounts(self, promo_group: PromoGroup) -> str:
+ try:
+ discounts_map = promo_group._get_period_discounts_map()
+ except Exception:
+ discounts_map = {}
+
+ if not discounts_map:
+ return "нет"
+
+ parts = [
+ f"{days} д: {percent}%"
+ for days, percent in sorted(discounts_map.items(), key=lambda item: item[0])
+ ]
+ return ", ".join(parts)
+
async def send_ticket_event_notification(
self,
text: str,
diff --git a/app/services/promocode_service.py b/app/services/promocode_service.py
index c48239ca..1a937913 100644
--- a/app/services/promocode_service.py
+++ b/app/services/promocode_service.py
@@ -65,7 +65,8 @@ class PromoCodeService:
return {
"success": True,
- "description": result_description
+ "description": result_description,
+ "promocode": promocode,
}
except Exception as e: