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: