diff --git a/app/database/models.py b/app/database/models.py index 26c4f860..d7f93679 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -571,7 +571,8 @@ class Subscription(Base): return 0.0 def extend_subscription(self, days: int): - + from datetime import timedelta, datetime + if self.end_date > datetime.utcnow(): self.end_date = self.end_date + timedelta(days=days) else: diff --git a/app/external/remnawave_api.py b/app/external/remnawave_api.py index 19f42d91..8949dad9 100644 --- a/app/external/remnawave_api.py +++ b/app/external/remnawave_api.py @@ -4,6 +4,7 @@ import ssl import base64 from datetime import datetime, timedelta from typing import Dict, List, Optional, Union, Any +from urllib.parse import urlparse import aiohttp import logging from dataclasses import dataclass diff --git a/app/handlers/admin/main.py b/app/handlers/admin/main.py index 3be7d267..aeaf15e1 100644 --- a/app/handlers/admin/main.py +++ b/app/handlers/admin/main.py @@ -128,6 +128,7 @@ async def show_support_submenu( # Moderators have access only to tickets and not to settings is_moderator_only = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id)) + from app.keyboards.admin import get_admin_support_submenu_keyboard kb = get_admin_support_submenu_keyboard(db_user.language) if is_moderator_only: # Rebuild keyboard to include only tickets and back to main menu diff --git a/app/handlers/admin/support_settings.py b/app/handlers/admin/support_settings.py index 5ab4d3af..adbf3ea7 100644 --- a/app/handlers/admin/support_settings.py +++ b/app/handlers/admin/support_settings.py @@ -4,7 +4,6 @@ import html import contextlib from aiogram import Dispatcher, types, F from aiogram.fsm.context import FSMContext -from aiogram.fsm.state import State, StatesGroup from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import User @@ -153,6 +152,24 @@ async def toggle_sla(callback: types.CallbackQuery, db_user: User, db: AsyncSess await show_support_settings(callback, db_user, db) +from app.states import SupportSettingsStates + +@admin_required +@error_handler +async def start_set_sla_minutes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): + await callback.message.edit_text( + "⏳ Настройка SLA\n\nВведите количество минут ожидания ответа (целое число > 0):", + parse_mode="HTML", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]] + ) + ) + await state.set_state(SupportSettingsStates.waiting_for_desc) # temporary reuse replaced below + # we'll manage separate state below + + +from aiogram.fsm.state import State, StatesGroup + class SupportAdvancedStates(StatesGroup): waiting_for_sla_minutes = State() waiting_for_moderator_id = State() diff --git a/app/handlers/admin/welcome_text.py b/app/handlers/admin/welcome_text.py index f4c2b353..aca691f9 100644 --- a/app/handlers/admin/welcome_text.py +++ b/app/handlers/admin/welcome_text.py @@ -3,6 +3,7 @@ from aiogram import Dispatcher, types, F from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession +from app.config import settings from app.database.models import User from app.states import AdminStates from app.keyboards.admin import get_welcome_text_keyboard, get_admin_main_keyboard @@ -43,16 +44,16 @@ async def show_welcome_text_panel( db_user: User, db: AsyncSession ): - welcome_settings = await get_current_welcome_text_settings(db) - status_emoji = "🟢" if welcome_settings['is_enabled'] else "🔴" - status_text = "включено" if welcome_settings['is_enabled'] else "отключено" + settings = await get_current_welcome_text_settings(db) + status_emoji = "🟢" if settings['is_enabled'] else "🔴" + status_text = "включено" if settings['is_enabled'] else "отключено" await callback.message.edit_text( f"👋 Управление приветственным текстом\n\n" f"{status_emoji} Статус: {status_text}\n\n" f"Здесь вы можете управлять текстом, который показывается новым пользователям после регистрации.\n\n" f"💡 Доступные плейсхолдеры для автозамены:", - reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']), + reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']), parse_mode="HTML" ) await callback.answer() @@ -88,11 +89,11 @@ async def show_current_welcome_text( db_user: User, db: AsyncSession ): - welcome_settings = await get_current_welcome_text_settings(db) - current_text = welcome_settings['text'] - is_enabled = welcome_settings['is_enabled'] - - if not welcome_settings['id']: + settings = await get_current_welcome_text_settings(db) + current_text = settings['text'] + is_enabled = settings['is_enabled'] + + if not settings['id']: status = "📝 Используется стандартный текст:" else: status = "📝 Текущий приветственный текст:" @@ -120,7 +121,7 @@ async def show_placeholders_help( db_user: User, db: AsyncSession ): - welcome_settings = await get_current_welcome_text_settings(db) + settings = await get_current_welcome_text_settings(db) placeholders = get_available_placeholders() placeholders_text = "\n".join([f"• {key}\n {desc}" for key, desc in placeholders.items()]) @@ -136,7 +137,7 @@ async def show_placeholders_help( await callback.message.edit_text( help_text, - reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']), + reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']), parse_mode="HTML" ) await callback.answer() @@ -148,12 +149,12 @@ async def show_formatting_help( db_user: User, db: AsyncSession ): - welcome_settings = await get_current_welcome_text_settings(db) + settings = await get_current_welcome_text_settings(db) formatting_info = get_telegram_formatting_info() await callback.message.edit_text( formatting_info, - reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']), + reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']), parse_mode="HTML" ) await callback.answer() @@ -166,8 +167,8 @@ async def start_edit_welcome_text( db_user: User, db: AsyncSession ): - welcome_settings = await get_current_welcome_text_settings(db) - current_text = welcome_settings['text'] + settings = await get_current_welcome_text_settings(db) + current_text = settings['text'] placeholders = get_available_placeholders() placeholders_text = "\n".join([f"• {key} - {desc}" for key, desc in placeholders.items()]) @@ -205,9 +206,9 @@ async def process_welcome_text_edit( success = await set_welcome_text(db, new_text, db_user.id) if success: - welcome_settings = await get_current_welcome_text_settings(db) - status_emoji = "🟢" if welcome_settings['is_enabled'] else "🔴" - status_text = "включено" if welcome_settings['is_enabled'] else "отключено" + settings = await get_current_welcome_text_settings(db) + status_emoji = "🟢" if settings['is_enabled'] else "🔴" + status_text = "включено" if settings['is_enabled'] else "отключено" placeholders = get_available_placeholders() placeholders_text = "\n".join([f"• {key}" for key in placeholders.keys()]) @@ -218,14 +219,14 @@ async def process_welcome_text_edit( f"Новый текст:\n" f"{new_text}\n\n" f"💡 Будут заменяться плейсхолдеры: {placeholders_text}", - reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']), + reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']), parse_mode="HTML" ) else: - welcome_settings = await get_current_welcome_text_settings(db) + settings = await get_current_welcome_text_settings(db) await message.answer( "❌ Ошибка при сохранении текста. Попробуйте еще раз.", - reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']) + reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']) ) await state.clear() @@ -241,9 +242,9 @@ async def reset_welcome_text( success = await set_welcome_text(db, default_text, db_user.id) if success: - welcome_settings = await get_current_welcome_text_settings(db) - status_emoji = "🟢" if welcome_settings['is_enabled'] else "🔴" - status_text = "включено" if welcome_settings['is_enabled'] else "отключено" + settings = await get_current_welcome_text_settings(db) + status_emoji = "🟢" if settings['is_enabled'] else "🔴" + status_text = "включено" if settings['is_enabled'] else "отключено" await callback.message.edit_text( f"✅ Приветственный текст сброшен на стандартный!\n\n" @@ -251,14 +252,14 @@ async def reset_welcome_text( f"Стандартный текст:\n" f"{default_text}\n\n" f"💡 Плейсхолдер {{user_name}} будет заменяться на имя пользователя", - reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']), + reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']), parse_mode="HTML" ) else: - welcome_settings = await get_current_welcome_text_settings(db) + settings = await get_current_welcome_text_settings(db) await callback.message.edit_text( "❌ Ошибка при сбросе текста. Попробуйте еще раз.", - reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']) + reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']) ) await callback.answer() @@ -280,14 +281,14 @@ async def show_preview_welcome_text( test_user = TestUser() preview_text = await get_welcome_text_for_user(db, test_user) - welcome_settings = await get_current_welcome_text_settings(db) + settings = await get_current_welcome_text_settings(db) if preview_text: await callback.message.edit_text( f"👁️ Предварительный просмотр\n\n" f"Как будет выглядеть текст для пользователя 'Иван' (@test_user):\n\n" f"{preview_text}", - reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']), + reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']), parse_mode="HTML" ) else: @@ -295,7 +296,7 @@ async def show_preview_welcome_text( f"👁️ Предварительный просмотр\n\n" f"🔴 Приветственные сообщения отключены.\n" f"Новые пользователи не будут получать приветственный текст после регистрации.", - reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']), + reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']), parse_mode="HTML" ) diff --git a/app/handlers/menu.py b/app/handlers/menu.py index a191d537..7c8d56d4 100644 --- a/app/handlers/menu.py +++ b/app/handlers/menu.py @@ -10,6 +10,7 @@ from app.database.crud.user import get_user_by_telegram_id, update_user from app.keyboards.inline import get_main_menu_keyboard, get_language_selection_keyboard from app.localization.texts import get_texts, get_rules from app.database.models import User +from app.utils.user_utils import mark_user_as_had_paid_subscription from app.database.crud.user_message import get_random_active_message from app.services.subscription_checkout_service import ( has_subscription_checkout_draft, @@ -64,6 +65,17 @@ async def show_main_menu( await callback.answer() +async def mark_user_as_had_paid_subscription( + db: AsyncSession, + user: User +) -> None: + if not user.has_had_paid_subscription: + user.has_had_paid_subscription = True + user.updated_at = datetime.utcnow() + await db.commit() + logger.info(f"🎯 Пользователь {user.telegram_id} отмечен как имевший платную подписку") + + async def show_service_rules( callback: types.CallbackQuery, db_user: User, diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 8c2ae0b4..f0b21c87 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -146,6 +146,12 @@ async def _prepare_subscription_summary( data: Dict[str, Any], texts, ) -> Tuple[str, Dict[str, Any]]: + from app.utils.pricing_utils import ( + calculate_months_from_days, + format_period_description, + validate_pricing_calculation, + apply_percentage_discount, + ) summary_data = dict(data) countries = await _get_available_countries(db_user.promo_group_id) @@ -1122,6 +1128,7 @@ async def return_to_saved_cart( ) return + from app.utils.pricing_utils import calculate_months_from_days, format_period_description countries = await _get_available_countries(db_user.promo_group_id) selected_countries_names = [] @@ -1326,6 +1333,7 @@ async def apply_countries_changes( db: AsyncSession, state: FSMContext ): + from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price logger.info(f"🔧 Применение изменений стран") @@ -1626,6 +1634,7 @@ async def confirm_change_devices( db_user: User, db: AsyncSession ): + from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price new_devices_count = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) @@ -1739,6 +1748,7 @@ async def execute_change_devices( db_user: User, db: AsyncSession ): + from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price callback_parts = callback.data.split('_') new_devices_count = int(callback_parts[3]) @@ -1869,6 +1879,7 @@ async def show_devices_page( page: int = 1 ): + from app.utils.pagination import paginate_list texts = get_texts(db_user.language) devices_per_page = 5 @@ -1967,6 +1978,7 @@ async def handle_single_device_reset( if response and 'response' in response: devices_list = response['response'].get('devices', []) + from app.utils.pagination import paginate_list devices_per_page = 5 pagination = paginate_list(devices_list, page=page, per_page=devices_per_page) @@ -2272,6 +2284,7 @@ async def confirm_add_devices( db_user: User, db: AsyncSession ): + from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price devices_count = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) @@ -2407,6 +2420,11 @@ async def confirm_extend_subscription( db_user: User, db: AsyncSession ): + from app.utils.pricing_utils import ( + calculate_months_from_days, + validate_pricing_calculation, + apply_percentage_discount, + ) from app.services.admin_notification_service import AdminNotificationService days = int(callback.data.split('_')[2]) @@ -3016,6 +3034,7 @@ async def select_country( return period_base_price = PERIOD_PRICES[data['period_days']] + from app.utils.pricing_utils import apply_percentage_discount discounted_base_price, _ = apply_percentage_discount( period_base_price, @@ -3151,6 +3170,7 @@ async def confirm_purchase( db_user: User, db: AsyncSession ): + from app.utils.pricing_utils import calculate_months_from_days, validate_pricing_calculation from app.services.admin_notification_service import AdminNotificationService data = await state.get_data() @@ -3870,6 +3890,7 @@ async def create_paid_subscription_with_traffic_mode( traffic_gb: Optional[int] = None ): from app.config import settings + from app.database.crud.subscription import create_paid_subscription if traffic_gb is None: if settings.is_traffic_fixed(): @@ -5330,6 +5351,7 @@ async def confirm_switch_traffic( db_user: User, db: AsyncSession ): + from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price new_traffic_gb = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) @@ -5443,6 +5465,7 @@ async def execute_switch_traffic( db_user: User, db: AsyncSession ): + from app.utils.pricing_utils import get_remaining_months callback_parts = callback.data.split('_') new_traffic_gb = int(callback_parts[3]) @@ -5527,6 +5550,7 @@ def get_traffic_switch_keyboard( subscription_end_date: datetime = None, discount_percent: int = 0, ) -> InlineKeyboardMarkup: + from app.utils.pricing_utils import get_remaining_months from app.config import settings months_multiplier = 1 diff --git a/app/handlers/tickets.py b/app/handlers/tickets.py index f4f5e301..a6445a00 100644 --- a/app/handlers/tickets.py +++ b/app/handlers/tickets.py @@ -412,6 +412,7 @@ async def show_my_tickets( # Добавим кнопку перехода к закрытым keyboard.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("VIEW_CLOSED_TICKETS", "🟢 Закрытые тикеты"), callback_data="my_tickets_closed")]) # Всегда используем фото-рендер с логотипом (утилита сама сделает фоллбек при необходимости) + from app.utils.photo_message import edit_or_answer_photo await edit_or_answer_photo( callback=callback, caption=texts.t("MY_TICKETS_TITLE", "📋 Ваши тикеты:"), @@ -455,6 +456,7 @@ async def show_my_tickets_closed( data = [{'id': t.id, 'title': t.title, 'status_emoji': t.status_emoji} for t in tickets] kb = get_my_tickets_keyboard(data, current_page=current_page, total_pages=total_pages, language=db_user.language, page_prefix="my_tickets_closed_page_") kb.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("BACK_TO_OPEN_TICKETS", "🔴 Открытые тикеты"), callback_data="my_tickets")]) + from app.utils.photo_message import edit_or_answer_photo await edit_or_answer_photo( callback=callback, caption=texts.t("CLOSED_TICKETS_TITLE", "🟢 Закрытые тикеты:"), diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py index 1aa3efeb..ad3ba206 100644 --- a/app/services/admin_notification_service.py +++ b/app/services/admin_notification_service.py @@ -701,7 +701,7 @@ class AdminNotificationService: if not payment_method: return '💰 С баланса' - return method_names.get(payment_method, '💰 С баланса') + return method_names.get(payment_method, f'💰 С баланса') def _format_traffic(self, traffic_gb: int) -> str: if traffic_gb == 0: @@ -748,32 +748,40 @@ class AdminNotificationService: if details.get("auto_enabled", False): icon = "⚠️" title = "АВТОМАТИЧЕСКОЕ ВКЛЮЧЕНИЕ ТЕХРАБОТ" + alert_type = "warning" else: icon = "🔧" title = "ВКЛЮЧЕНИЕ ТЕХРАБОТ" + alert_type = "info" elif event_type == "disable": icon = "✅" title = "ОТКЛЮЧЕНИЕ ТЕХРАБОТ" + alert_type = "success" elif event_type == "api_status": if status == "online": icon = "🟢" title = "API REMNAWAVE ВОССТАНОВЛЕНО" + alert_type = "success" else: icon = "🔴" title = "API REMNAWAVE НЕДОСТУПНО" + alert_type = "error" elif event_type == "monitoring": if status == "started": icon = "🔍" title = "МОНИТОРИНГ ЗАПУЩЕН" + alert_type = "info" else: icon = "⏹️" title = "МОНИТОРИНГ ОСТАНОВЛЕН" + alert_type = "info" else: icon = "ℹ️" title = "СИСТЕМА ТЕХРАБОТ" + alert_type = "info" message_parts = [f"{icon} {title}", ""] @@ -963,6 +971,103 @@ class AdminNotificationService: logger.error(f"Ошибка отправки уведомления о статусе панели Remnawave: {e}") return False + async def send_remnawave_panel_status_notification( + self, + status: str, + details: Dict[str, Any] = None + ) -> bool: + if not self._is_enabled(): + return False + + try: + details = details or {} + + status_config = { + "online": {"icon": "🟢", "title": "ПАНЕЛЬ REMNAWAVE ДОСТУПНА", "alert_type": "success"}, + "offline": {"icon": "🔴", "title": "ПАНЕЛЬ REMNAWAVE НЕДОСТУПНА", "alert_type": "error"}, + "degraded": {"icon": "🟡", "title": "ПАНЕЛЬ REMNAWAVE РАБОТАЕТ СО СБОЯМИ", "alert_type": "warning"}, + "maintenance": {"icon": "🔧", "title": "ПАНЕЛЬ REMNAWAVE НА ОБСЛУЖИВАНИИ", "alert_type": "info"} + } + + config = status_config.get(status, status_config["offline"]) + + message_parts = [ + f"{config['icon']} {config['title']}", + "" + ] + + if details.get("api_url"): + message_parts.append(f"🔗 URL: {details['api_url']}") + + if details.get("response_time"): + message_parts.append(f"⚡ Время отклика: {details['response_time']} сек") + + if details.get("last_check"): + last_check = details["last_check"] + if isinstance(last_check, str): + from datetime import datetime + last_check = datetime.fromisoformat(last_check) + message_parts.append(f"🕐 Последняя проверка: {last_check.strftime('%H:%M:%S')}") + + if status == "online": + if details.get("uptime"): + message_parts.append(f"⏱️ Время работы: {details['uptime']}") + + if details.get("users_online"): + message_parts.append(f"👥 Пользователей онлайн: {details['users_online']}") + + message_parts.append("") + message_parts.append("✅ Все системы работают нормально.") + + elif status == "offline": + if details.get("error"): + error_msg = str(details["error"])[:150] + message_parts.append(f"❌ Ошибка: {error_msg}") + + if details.get("consecutive_failures"): + message_parts.append(f"🔄 Неудачных попыток: {details['consecutive_failures']}") + + message_parts.append("") + message_parts.append("⚠️ Панель недоступна. Проверьте соединение и статус сервера.") + + elif status == "degraded": + if details.get("issues"): + issues = details["issues"] + if isinstance(issues, list): + message_parts.append("⚠️ Обнаруженные проблемы:") + for issue in issues[:3]: + message_parts.append(f" • {issue}") + else: + message_parts.append(f"⚠️ Проблема: {issues}") + + message_parts.append("") + message_parts.append("Панель работает, но возможны задержки или сбои.") + + elif status == "maintenance": + if details.get("maintenance_reason"): + message_parts.append(f"🔧 Причина: {details['maintenance_reason']}") + + if details.get("estimated_duration"): + message_parts.append(f"⏰ Ожидаемая длительность: {details['estimated_duration']}") + + if details.get("manual_message"): + message_parts.append(f"💬 Сообщение: {details['manual_message']}") + + message_parts.append("") + message_parts.append("Панель временно недоступна для обслуживания.") + + from datetime import datetime + message_parts.append("") + message_parts.append(f"⏰ {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}") + + message = "\n".join(message_parts) + + return await self._send_message(message) + + except Exception as e: + logger.error(f"Ошибка отправки уведомления о статусе панели Remnawave: {e}") + return False + async def send_subscription_update_notification( self, db: AsyncSession, diff --git a/app/services/notification_settings_service.py b/app/services/notification_settings_service.py index 959bf93e..a19edffd 100644 --- a/app/services/notification_settings_service.py +++ b/app/services/notification_settings_service.py @@ -1,4 +1,5 @@ import json +import json import logging from copy import deepcopy from pathlib import Path diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index 9acaf501..1a02a08f 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -987,6 +987,15 @@ class RemnaWaveService: logger.error(f"Error removing users from squad: {e}") return False + async def delete_squad(self, squad_uuid: str) -> bool: + try: + async with self.get_api_client() as api: + response = await api.delete_internal_squad(squad_uuid) + return response + except Exception as e: + logger.error(f"Error deleting squad: {e}") + return False + async def get_all_inbounds(self) -> List[Dict]: try: async with self.get_api_client() as api: @@ -1021,6 +1030,15 @@ class RemnaWaveService: logger.error(f"Error renaming squad: {e}") return False + async def create_squad(self, name: str, inbound_uuids: List[str]) -> bool: + try: + async with self.get_api_client() as api: + squad = await api.create_internal_squad(name, inbound_uuids) + return squad is not None + except Exception as e: + logger.error(f"Error creating squad: {e}") + return False + async def get_node_user_usage_by_range(self, node_uuid: str, start_date, end_date) -> List[Dict[str, Any]]: try: async with self.get_api_client() as api: diff --git a/app/utils/user_utils.py b/app/utils/user_utils.py index e2a73c49..8292f5e1 100644 --- a/app/utils/user_utils.py +++ b/app/utils/user_utils.py @@ -189,7 +189,7 @@ async def get_detailed_referral_list(db: AsyncSession, user_id: int, limit: int and_( Transaction.user_id == referral.id, Transaction.type == TransactionType.DEPOSIT.value, - Transaction.is_completed.is_(True) + Transaction.is_completed == True ) ) )