From 36f9e495b5701450842af24320c430f3c9e03c9b Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Fri, 15 Aug 2025 04:02:22 +0300 Subject: [PATCH] Added TG bot --- mysite/settings.py | 6 + requirements.txt | 1 + static/admin/css/main.css | 239 ++ telegram_bot/__init__.py | 0 telegram_bot/admin.py | 621 +++++ telegram_bot/apps.py | 75 + telegram_bot/bot.py | 1060 +++++++++ telegram_bot/localization.py | 207 ++ telegram_bot/management/__init__.py | 0 telegram_bot/management/commands/__init__.py | 0 .../management/commands/run_telegram_bot.py | 99 + .../commands/telegram_bot_status.py | 112 + telegram_bot/migrations/0001_initial.py | 70 + .../0002_add_connection_settings.py | 33 + telegram_bot/migrations/0003_accessrequest.py | 42 + ..._telegram_bo_status_cf9310_idx_and_more.py | 37 + ...ve_botsettings_welcome_message_and_more.py | 25 + .../0006_accessrequest_desired_username.py | 18 + ...emove_botsettings_help_message_and_more.py | 27 + telegram_bot/migrations/__init__.py | 0 telegram_bot/models.py | 292 +++ telegram_bot/tests.py | 3 + telegram_bot/views.py | 3 + telegram_bot_locks/telegram_bot.lock | 0 vpn/admin.py | 2094 +---------------- vpn/admin/__init__.py | 45 + vpn/admin/access.py | 485 ++++ vpn/admin/base.py | 57 + vpn/admin/logs.py | 179 ++ vpn/admin/server.py | 826 +++++++ vpn/admin/user.py | 604 +++++ vpn/admin_minimal.py | 73 + vpn/admin_test.py | 16 + vpn/admin_xray.py | 256 +- vpn/apps.py | 96 + ...t_name_user_telegram_last_name_and_more.py | 38 + .../0026_alter_subscriptiongroup_options.py | 17 + vpn/models.py | 32 + vpn/models_xray.py | 4 +- vpn/server_plugins/xray_v2.py | 11 +- vpn/templates/admin/purge_users.html | 304 +++ .../admin/vpn/certificate/change_form.html | 18 + .../admin/vpn/certificate/change_list.html | 18 + .../admin/vpn/inbound/change_form.html | 18 + .../admin/vpn/inbound/change_list.html | 18 + .../admin/vpn/server/change_form.html | 39 + .../admin/vpn/server/change_list.html | 166 ++ .../vpn/subscriptiongroup/change_form.html | 12 +- .../vpn/subscriptiongroup/change_list.html | 12 +- .../vpn/usersubscription/change_form.html | 14 +- .../vpn/usersubscription/change_list.html | 14 +- vpn/utils.py | 21 + 52 files changed, 6376 insertions(+), 2081 deletions(-) create mode 100644 static/admin/css/main.css create mode 100644 telegram_bot/__init__.py create mode 100644 telegram_bot/admin.py create mode 100644 telegram_bot/apps.py create mode 100644 telegram_bot/bot.py create mode 100644 telegram_bot/localization.py create mode 100644 telegram_bot/management/__init__.py create mode 100644 telegram_bot/management/commands/__init__.py create mode 100644 telegram_bot/management/commands/run_telegram_bot.py create mode 100644 telegram_bot/management/commands/telegram_bot_status.py create mode 100644 telegram_bot/migrations/0001_initial.py create mode 100644 telegram_bot/migrations/0002_add_connection_settings.py create mode 100644 telegram_bot/migrations/0003_accessrequest.py create mode 100644 telegram_bot/migrations/0004_remove_accessrequest_telegram_bo_status_cf9310_idx_and_more.py create mode 100644 telegram_bot/migrations/0005_delete_botstatus_remove_botsettings_welcome_message_and_more.py create mode 100644 telegram_bot/migrations/0006_accessrequest_desired_username.py create mode 100644 telegram_bot/migrations/0007_remove_botsettings_help_message_and_more.py create mode 100644 telegram_bot/migrations/__init__.py create mode 100644 telegram_bot/models.py create mode 100644 telegram_bot/tests.py create mode 100644 telegram_bot/views.py create mode 100644 telegram_bot_locks/telegram_bot.lock create mode 100644 vpn/admin/__init__.py create mode 100644 vpn/admin/access.py create mode 100644 vpn/admin/base.py create mode 100644 vpn/admin/logs.py create mode 100644 vpn/admin/server.py create mode 100644 vpn/admin/user.py create mode 100644 vpn/admin_minimal.py create mode 100644 vpn/admin_test.py create mode 100644 vpn/migrations/0025_user_telegram_first_name_user_telegram_last_name_and_more.py create mode 100644 vpn/migrations/0026_alter_subscriptiongroup_options.py create mode 100644 vpn/templates/admin/purge_users.html create mode 100644 vpn/templates/admin/vpn/certificate/change_form.html create mode 100644 vpn/templates/admin/vpn/certificate/change_list.html create mode 100644 vpn/templates/admin/vpn/inbound/change_form.html create mode 100644 vpn/templates/admin/vpn/inbound/change_list.html create mode 100644 vpn/templates/admin/vpn/server/change_form.html create mode 100644 vpn/templates/admin/vpn/server/change_list.html create mode 100644 vpn/utils.py diff --git a/mysite/settings.py b/mysite/settings.py index b3aed1c..4d6211e 100644 --- a/mysite/settings.py +++ b/mysite/settings.py @@ -89,6 +89,11 @@ LOGGING = { 'level': 'DEBUG', 'propagate': False, }, + 'telegram_bot': { + 'handlers': ['console'], + 'level': 'DEBUG', + 'propagate': False, + }, 'requests': { 'handlers': ['console'], 'level': 'INFO', @@ -115,6 +120,7 @@ INSTALLED_APPS = [ 'django_celery_results', 'django_celery_beat', 'vpn', + 'telegram_bot', ] MIDDLEWARE = [ diff --git a/requirements.txt b/requirements.txt index 0e94a80..5d87110 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,3 +19,4 @@ cryptography==45.0.5 acme>=2.0.0 cloudflare>=4.3.1 josepy>=2.0.0 +python-telegram-bot==21.10 diff --git a/static/admin/css/main.css b/static/admin/css/main.css new file mode 100644 index 0000000..bf958b4 --- /dev/null +++ b/static/admin/css/main.css @@ -0,0 +1,239 @@ +/* static/admin/css/main.css */ + +/* Bulk Action Section Styling */ +.bulk-actions-section { + background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); + border: 1px solid #dee2e6; + border-left: 4px solid #007cba; + border-radius: 8px; + padding: 20px; + margin: 20px 0; + box-shadow: 0 2px 4px rgba(0,0,0,0.05); +} + +.bulk-actions-section h3 { + color: #007cba; + margin-top: 0; + display: flex; + align-items: center; + gap: 8px; + font-size: 18px; +} + +.bulk-actions-section p { + color: #6c757d; + margin-bottom: 15px; + line-height: 1.5; +} + +/* Action Button Styles */ +.server-action-btn, .bulk-action-btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 15px; + border-radius: 6px; + text-decoration: none; + font-weight: 500; + font-size: 14px; + border: none; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + position: relative; + overflow: hidden; +} + +.server-action-btn:before, .bulk-action-btn:before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent); + transition: left 0.5s; +} + +.server-action-btn:hover:before, .bulk-action-btn:hover:before { + left: 100%; +} + +.server-action-btn:hover, .bulk-action-btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + text-decoration: none; +} + +/* Specific button colors */ +.btn-move-clients { + background-color: #007cba; + color: white; +} + +.btn-move-clients:hover { + background-color: #005a8b !important; + color: white; +} + +.btn-purge-users { + background-color: #dc3545; + color: white; +} + +.btn-purge-users:hover { + background-color: #c82333 !important; + color: white; +} + +/* Server list action buttons */ +.field-server_actions { + min-width: 160px; +} + +.field-server_actions .server-action-btn { + padding: 5px 8px; + font-size: 11px; + gap: 4px; + margin: 2px; +} + +/* Server statistics section */ +.server-stats-section { + background-color: #e8f4fd; + border: 1px solid #bee5eb; + border-radius: 6px; + padding: 12px; + margin: 15px 0; +} + +.server-stats-grid { + display: flex; + gap: 20px; + flex-wrap: wrap; + align-items: center; +} + +.stat-item { + display: flex; + align-items: center; + gap: 5px; +} + +.stat-label { + color: #495057; + font-weight: 500; +} + +.stat-value { + color: #007cba; + font-weight: bold; +} + +/* Tip section styling */ +.tip-section { + background-color: rgba(255, 243, 205, 0.8); + border-left: 4px solid #ffc107; + border-radius: 4px; + padding: 12px; + margin-top: 15px; +} + +.tip-section small { + color: #856404; + line-height: 1.4; +} + +/* Loading states */ +.server-action-btn.loading { + pointer-events: none; + opacity: 0.7; +} + +.server-action-btn.loading:after { + content: ''; + position: absolute; + width: 16px; + height: 16px; + margin: auto; + border: 2px solid transparent; + border-top-color: #ffffff; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Responsive design */ +@media (max-width: 768px) { + .bulk-actions-section { + padding: 15px; + } + + .server-action-btn, .bulk-action-btn { + width: 100%; + justify-content: center; + margin-bottom: 8px; + } + + .server-stats-grid { + flex-direction: column; + align-items: flex-start; + gap: 10px; + } + + .field-server_actions > div { + flex-direction: column; + } + + .field-server_actions .server-action-btn { + width: 100%; + justify-content: center; + margin: 2px 0; + } +} + +@media (max-width: 480px) { + .bulk-actions-section h3 { + font-size: 16px; + } + + .server-action-btn, .bulk-action-btn { + font-size: 13px; + padding: 8px 12px; + } +} + +/* Dark mode support */ +@media (prefers-color-scheme: dark) { + .bulk-actions-section { + background: linear-gradient(135deg, #2d3748 0%, #4a5568 100%); + border-color: #4a5568; + color: #e2e8f0; + } + + .bulk-actions-section h3 { + color: #63b3ed; + } + + .bulk-actions-section p { + color: #a0aec0; + } + + .server-stats-section { + background-color: #2d3748; + border-color: #4a5568; + color: #e2e8f0; + } + + .stat-label { + color: #a0aec0; + } + + .stat-value { + color: #63b3ed; + } +} diff --git a/telegram_bot/__init__.py b/telegram_bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/telegram_bot/admin.py b/telegram_bot/admin.py new file mode 100644 index 0000000..34b9838 --- /dev/null +++ b/telegram_bot/admin.py @@ -0,0 +1,621 @@ +from django.contrib import admin +from django.utils.html import format_html +from django.urls import path, reverse +from django.shortcuts import redirect +from django.contrib import messages +from django.utils import timezone +from .models import BotSettings, TelegramMessage, AccessRequest +from .localization import MessageLocalizer +import logging + +logger = logging.getLogger(__name__) + + +@admin.register(BotSettings) +class BotSettingsAdmin(admin.ModelAdmin): + list_display = ('__str__', 'enabled', 'bot_token_display', 'updated_at') + fieldsets = ( + ('Bot Configuration', { + 'fields': ('bot_token', 'enabled', 'bot_status_display'), + 'description': 'Configure bot settings and view current status' + }), + ('Connection Settings', { + 'fields': ('api_base_url', 'connection_timeout', 'use_proxy', 'proxy_url'), + 'classes': ('collapse',) + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + readonly_fields = ('created_at', 'updated_at', 'bot_status_display') + + def bot_token_display(self, obj): + """Mask bot token for security""" + if obj.bot_token: + token = obj.bot_token + if len(token) > 10: + return f"{token[:6]}...{token[-4:]}" + return "Token set" + return "No token set" + bot_token_display.short_description = "Bot Token" + + def bot_status_display(self, obj): + """Display bot status with control buttons""" + from .bot import TelegramBotManager + import os + from django.conf import settings as django_settings + + manager = TelegramBotManager() + + # Check if lock file exists - only reliable indicator + lock_dir = os.path.join(getattr(django_settings, 'BASE_DIR', '/tmp'), 'telegram_bot_locks') + lock_path = os.path.join(lock_dir, 'telegram_bot.lock') + is_running = os.path.exists(lock_path) + + if is_running: + status_html = '🟒 Bot is RUNNING' + else: + status_html = 'πŸ”΄ Bot is STOPPED' + + # Add control buttons + status_html += '

' + if is_running: + status_html += f'Stop Bot ' + status_html += f'Restart Bot' + else: + if obj.enabled and obj.bot_token: + status_html += f'Start Bot' + else: + status_html += 'Configure bot token and enable bot to start' + + return format_html(status_html) + bot_status_display.short_description = "Bot Status" + + def get_urls(self): + urls = super().get_urls() + custom_urls = [ + path('start-bot/', self.start_bot, name='telegram_bot_start_bot'), + path('stop-bot/', self.stop_bot, name='telegram_bot_stop_bot'), + path('restart-bot/', self.restart_bot, name='telegram_bot_restart_bot'), + ] + return custom_urls + urls + + def start_bot(self, request): + """Start the telegram bot""" + try: + from .bot import TelegramBotManager + manager = TelegramBotManager() + manager.start() + messages.success(request, "Bot started successfully!") + except Exception as e: + messages.error(request, f"Failed to start bot: {e}") + logger.error(f"Failed to start bot: {e}") + + return redirect('admin:telegram_bot_botsettings_change', object_id=1) + + def stop_bot(self, request): + """Stop the telegram bot""" + try: + from .bot import TelegramBotManager + manager = TelegramBotManager() + manager.stop() + messages.success(request, "Bot stopped successfully!") + except Exception as e: + messages.error(request, f"Failed to stop bot: {e}") + logger.error(f"Failed to stop bot: {e}") + + return redirect('admin:telegram_bot_botsettings_change', object_id=1) + + def restart_bot(self, request): + """Restart the telegram bot""" + try: + from .bot import TelegramBotManager + manager = TelegramBotManager() + manager.restart() + messages.success(request, "Bot restarted successfully!") + except Exception as e: + messages.error(request, f"Failed to restart bot: {e}") + logger.error(f"Failed to restart bot: {e}") + + return redirect('admin:telegram_bot_botsettings_change', object_id=1) + + def has_add_permission(self, request): + # Prevent creating multiple instances + return not BotSettings.objects.exists() + + def has_delete_permission(self, request, obj=None): + # Prevent deletion + return False + + +@admin.register(TelegramMessage) +class TelegramMessageAdmin(admin.ModelAdmin): + list_display = ( + 'created_at', + 'direction_display', + 'user_display', + 'language_display', + 'message_preview', + 'linked_user' + ) + list_filter = ( + 'direction', + 'created_at', + ('linked_user', admin.EmptyFieldListFilter), + ) + search_fields = ( + 'telegram_username', + 'telegram_first_name', + 'telegram_last_name', + 'message_text', + 'telegram_user_id' + ) + readonly_fields = ( + 'direction', + 'telegram_user_id', + 'telegram_username', + 'telegram_first_name', + 'telegram_last_name', + 'chat_id', + 'message_id', + 'message_text', + 'raw_data_display', + 'created_at', + 'linked_user', + 'user_language' + ) + + fieldsets = ( + ('Message Info', { + 'fields': ( + 'direction', + 'message_text', + 'created_at' + ) + }), + ('Telegram User', { + 'fields': ( + 'telegram_user_id', + 'telegram_username', + 'telegram_first_name', + 'telegram_last_name', + ) + }), + ('Technical Details', { + 'fields': ( + 'chat_id', + 'message_id', + 'linked_user', + 'raw_data_display' + ), + 'classes': ('collapse',) + }) + ) + + ordering = ['-created_at'] + list_per_page = 50 + date_hierarchy = 'created_at' + + def direction_display(self, obj): + """Display direction with icon""" + if obj.direction == 'incoming': + return format_html('⬇️ Incoming') + else: + return format_html('⬆️ Outgoing') + direction_display.short_description = "Direction" + + def user_display(self, obj): + """Display user info""" + display = obj.display_name + if obj.telegram_user_id: + display += f" (ID: {obj.telegram_user_id})" + return display + user_display.short_description = "Telegram User" + + def language_display(self, obj): + """Display user language""" + lang_map = {'ru': 'πŸ‡·πŸ‡Ί RU', 'en': 'πŸ‡ΊπŸ‡Έ EN'} + return lang_map.get(obj.user_language, obj.user_language or 'Unknown') + language_display.short_description = "Language" + + def message_preview(self, obj): + """Show message preview""" + if len(obj.message_text) > 100: + return obj.message_text[:100] + "..." + return obj.message_text + message_preview.short_description = "Message" + + def raw_data_display(self, obj): + """Display raw data as formatted JSON""" + import json + if obj.raw_data: + formatted = json.dumps(obj.raw_data, indent=2, ensure_ascii=False) + return format_html('
{}
', formatted) + return "No raw data" + raw_data_display.short_description = "Raw Data" + + def has_add_permission(self, request): + # Messages are created automatically by bot + return False + + def has_change_permission(self, request, obj=None): + # Messages are read-only + return False + + def has_delete_permission(self, request, obj=None): + # Allow deletion for cleanup + return request.user.is_superuser + + def get_actions(self, request): + """Add custom actions""" + actions = super().get_actions(request) + if not request.user.is_superuser: + # Remove delete action for non-superusers + if 'delete_selected' in actions: + del actions['delete_selected'] + return actions + + +@admin.register(AccessRequest) +class AccessRequestAdmin(admin.ModelAdmin): + list_display = ( + 'created_at', + 'user_display', + 'approved_display', + 'language_display', + 'desired_username_display', + 'message_preview', + 'created_user', + 'processed_by' + ) + list_filter = ( + 'approved', + 'created_at', + 'processed_at', + ('processed_by', admin.EmptyFieldListFilter), + ) + search_fields = ( + 'telegram_username', + 'telegram_first_name', + 'telegram_last_name', + 'telegram_user_id', + 'message_text' + ) + readonly_fields = ( + 'telegram_user_id', + 'telegram_username', + 'telegram_first_name', + 'telegram_last_name', + 'message_text', + 'chat_id', + 'created_at', + 'first_message', + 'processed_at', + 'processed_by', + 'created_user', + 'user_language' + ) + + fieldsets = ( + ('Request Info', { + 'fields': ( + 'approved', + 'admin_comment', + 'created_at', + 'processed_at', + 'processed_by' + ) + }), + ('User Creation', { + 'fields': ( + 'desired_username', + ), + 'description': 'Edit username before approving the request' + }), + ('Telegram User', { + 'fields': ( + 'telegram_user_id', + 'telegram_username', + 'telegram_first_name', + 'telegram_last_name', + ) + }), + ('Message Details', { + 'fields': ( + 'message_text', + 'chat_id', + 'first_message' + ), + 'classes': ('collapse',) + }), + ('Processing Results', { + 'fields': ( + 'created_user', + ) + }) + ) + + ordering = ['-created_at'] + list_per_page = 50 + date_hierarchy = 'created_at' + actions = ['approve_requests'] + + def user_display(self, obj): + """Display user info""" + return obj.display_name + user_display.short_description = "Telegram User" + + def approved_display(self, obj): + """Display approved status with colors""" + if obj.approved: + return format_html('βœ… Approved') + else: + return format_html('πŸ”„ Pending') + approved_display.short_description = "Status" + + def message_preview(self, obj): + """Show message preview""" + if len(obj.message_text) > 100: + return obj.message_text[:100] + "..." + return obj.message_text + message_preview.short_description = "Message" + + def desired_username_display(self, obj): + """Display desired username""" + if obj.desired_username: + return obj.desired_username + else: + fallback = obj.telegram_username or obj.telegram_first_name or f"tg_{obj.telegram_user_id}" + return format_html('{}', fallback) + desired_username_display.short_description = "Desired Username" + + def language_display(self, obj): + """Display user language with flag""" + lang_map = {'ru': 'πŸ‡·πŸ‡Ί RU', 'en': 'πŸ‡ΊπŸ‡Έ EN'} + return lang_map.get(obj.user_language, obj.user_language or 'Unknown') + language_display.short_description = "Language" + + def approve_requests(self, request, queryset): + """Approve selected access requests""" + pending_requests = queryset.filter(approved=False) + count = 0 + errors = [] + + for access_request in pending_requests: + try: + logger.info(f"Approving request {access_request.id} from user {access_request.telegram_user_id}") + user = self._create_user_from_request(access_request, request.user) + + if user: + access_request.approved = True + access_request.processed_by = request.user + access_request.processed_at = timezone.now() + access_request.created_user = user + access_request.save() + + logger.info(f"Successfully approved request {access_request.id}, created user {user.username}") + + # Send notification to user + self._send_approval_notification(access_request) + count += 1 + else: + errors.append(f"Failed to create user for {access_request.display_name}") + + except Exception as e: + error_msg = f"Failed to approve request from {access_request.display_name}: {e}" + logger.error(error_msg) + errors.append(error_msg) + + if count: + messages.success(request, f"Successfully approved {count} request(s)") + + if errors: + for error in errors: + messages.error(request, error) + + approve_requests.short_description = "βœ… Approve selected requests" + + + def _create_user_from_request(self, access_request, admin_user): + """Create User from AccessRequest or link to existing user""" + from vpn.models import User + import secrets + import string + + try: + # Check if user already exists by telegram_user_id + existing_user = User.objects.filter(telegram_user_id=access_request.telegram_user_id).first() + if existing_user: + logger.info(f"User already exists: {existing_user.username}") + return existing_user + + # Check if we can link to existing user by telegram_username + if access_request.telegram_username: + existing_user_by_username = User.objects.filter( + telegram_username__iexact=access_request.telegram_username, + telegram_user_id__isnull=True # Not yet linked to Telegram + ).first() + + if existing_user_by_username: + # Link telegram data to existing user + logger.info(f"Linking Telegram @{access_request.telegram_username} to existing user {existing_user_by_username.username}") + existing_user_by_username.telegram_user_id = access_request.telegram_user_id + existing_user_by_username.telegram_username = access_request.telegram_username + existing_user_by_username.telegram_first_name = access_request.telegram_first_name or "" + existing_user_by_username.telegram_last_name = access_request.telegram_last_name or "" + existing_user_by_username.save() + return existing_user_by_username + + # Use desired_username if provided, otherwise fallback to Telegram data + username = access_request.desired_username + if not username: + # Fallback to telegram_username, first_name or user_id + username = access_request.telegram_username or access_request.telegram_first_name or f"tg_{access_request.telegram_user_id}" + + # Clean username (remove special characters) + username = ''.join(c for c in username if c.isalnum() or c in '_-').lower() + if not username: + username = f"tg_{access_request.telegram_user_id}" + + # Make sure username is unique + original_username = username + counter = 1 + while User.objects.filter(username=username).exists(): + username = f"{original_username}_{counter}" + counter += 1 + + # Create new user since no existing user found to link + # Generate random password + password = ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(12)) + + logger.info(f"Creating new user with username: {username}") + + # Create user + user = User.objects.create_user( + username=username, + password=password, + first_name=access_request.telegram_first_name or "", + last_name=access_request.telegram_last_name or "", + telegram_user_id=access_request.telegram_user_id, + telegram_username=access_request.telegram_username or "", + telegram_first_name=access_request.telegram_first_name or "", + telegram_last_name=access_request.telegram_last_name or "", + is_active=True + ) + + logger.info(f"Successfully created user {user.username} (ID: {user.id}) from Telegram request {access_request.id}") + return user + + except Exception as e: + logger.error(f"Error creating user from request {access_request.id}: {e}") + raise + + def _send_approval_notification(self, access_request): + """Send approval notification via Telegram""" + try: + from .models import BotSettings + from telegram import Bot + import asyncio + + settings = BotSettings.get_settings() + if not settings.enabled or not settings.bot_token: + logger.warning("Bot not configured, skipping notification") + return + + # Create a simple Bot instance for sending notification + # This bypasses the need for the running bot manager + async def send_notification(): + try: + # Create bot with custom request settings + from telegram.request import HTTPXRequest + + request_kwargs = { + 'connection_pool_size': 1, + 'read_timeout': settings.connection_timeout, + 'write_timeout': settings.connection_timeout, + 'connect_timeout': settings.connection_timeout, + } + + if settings.use_proxy and settings.proxy_url: + request_kwargs['proxy'] = settings.proxy_url + + request = HTTPXRequest(**request_kwargs) + bot = Bot(token=settings.bot_token, request=request) + + # Send localized approval message with new keyboard + from telegram import ReplyKeyboardMarkup, KeyboardButton + language = access_request.user_language or 'en' + + # Get localized texts + message = MessageLocalizer.get_message('approval_notification', language) + access_btn_text = MessageLocalizer.get_button_text('access', language) + + # Create keyboard with Access button + keyboard = [[KeyboardButton(access_btn_text)]] + reply_markup = ReplyKeyboardMarkup( + keyboard, + resize_keyboard=True, + one_time_keyboard=False + ) + + await bot.send_message( + chat_id=access_request.telegram_user_id, + text=message, + reply_markup=reply_markup + ) + + logger.info(f"Sent approval notification to {access_request.telegram_user_id}") + + except Exception as e: + logger.error(f"Failed to send Telegram message: {e}") + finally: + try: + # Clean up bot connection + await request.shutdown() + except: + pass + + # Run in thread to avoid blocking admin interface + import threading + + def run_async_notification(): + try: + asyncio.run(send_notification()) + except Exception as e: + logger.error(f"Error in notification thread: {e}") + + thread = threading.Thread(target=run_async_notification, daemon=True) + thread.start() + + except Exception as e: + logger.error(f"Failed to send approval notification: {e}") + + + def has_add_permission(self, request): + # Requests are created by bot + return False + + def has_change_permission(self, request, obj=None): + # Allow changing only status and comment + return True + + def save_model(self, request, obj, form, change): + """Automatically handle approval and user creation""" + # Check if this is a change to approved + was_approved = False + + # If desired_username was changed and is empty, set default from Telegram data + if change and 'desired_username' in form.changed_data and not obj.desired_username: + obj.desired_username = obj.telegram_username or obj.telegram_first_name or f"tg_{obj.telegram_user_id}" + + if change and 'approved' in form.changed_data and obj.approved: + # Set processed_by and processed_at + if not obj.processed_by: + obj.processed_by = request.user + if not obj.processed_at: + obj.processed_at = timezone.now() + was_approved = True + + # If approved and no user created yet, create user + if was_approved and not obj.created_user: + try: + logger.info(f"Auto-creating user for approved request {obj.id}") + user = self._create_user_from_request(obj, request.user) + if user: + obj.created_user = user + messages.success(request, f"User '{user.username}' created successfully!") + logger.info(f"Auto-created user {user.username} for request {obj.id}") + + # Send approval notification + self._send_approval_notification(obj) + else: + messages.error(request, f"Failed to create user for approved request {obj.id}") + except Exception as e: + messages.error(request, f"Error creating user: {e}") + logger.error(f"Error auto-creating user for request {obj.id}: {e}") + + # Save the object + super().save_model(request, obj, form, change) + + diff --git a/telegram_bot/apps.py b/telegram_bot/apps.py new file mode 100644 index 0000000..92ec9b5 --- /dev/null +++ b/telegram_bot/apps.py @@ -0,0 +1,75 @@ +from django.apps import AppConfig +import logging + +logger = logging.getLogger(__name__) + + +class TelegramBotConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'telegram_bot' + + def ready(self): + """Called when Django starts - attempt to auto-start bot if enabled""" + import sys + import os + + # Skip auto-start in various scenarios + skip_conditions = [ + # Management commands + 'migrate' in sys.argv, + 'makemigrations' in sys.argv, + 'collectstatic' in sys.argv, + 'shell' in sys.argv, + 'test' in sys.argv, + # Celery processes + 'celery' in sys.argv, + 'worker' in sys.argv, + 'beat' in sys.argv, + # Environment variables that indicate worker/beat processes + os.environ.get('CELERY_WORKER_NAME'), + os.environ.get('CELERY_BEAT'), + # Process name detection + any('celery' in arg.lower() for arg in sys.argv), + any('worker' in arg.lower() for arg in sys.argv), + any('beat' in arg.lower() for arg in sys.argv), + ] + + if any(skip_conditions): + logger.info(f"Skipping Telegram bot auto-start in process: {' '.join(sys.argv)}") + return + + # Additional process detection by checking if we're in main process + try: + # Check if this is the main Django process (not a worker) + current_process = os.environ.get('DJANGO_SETTINGS_MODULE') + if not current_process: + logger.info("Skipping bot auto-start: not in main Django process") + return + except: + pass + + # Delay import to avoid circular imports + try: + from .bot import TelegramBotManager + import threading + import time + + def delayed_autostart(): + # Wait a bit for Django to fully initialize + time.sleep(2) + try: + manager = TelegramBotManager() + if manager.auto_start_if_enabled(): + logger.info("Telegram bot auto-started successfully") + else: + logger.info("Telegram bot auto-start skipped (disabled or already running)") + except Exception as e: + logger.error(f"Failed to auto-start Telegram bot: {e}") + + logger.info("Starting Telegram bot auto-start thread") + # Start in background thread to not block Django startup + thread = threading.Thread(target=delayed_autostart, daemon=True) + thread.start() + + except Exception as e: + logger.error(f"Error setting up Telegram bot auto-start: {e}") diff --git a/telegram_bot/bot.py b/telegram_bot/bot.py new file mode 100644 index 0000000..83a9f6f --- /dev/null +++ b/telegram_bot/bot.py @@ -0,0 +1,1060 @@ +import logging +import threading +import time +import asyncio +import os +import fcntl +from typing import Optional +from telegram import Update, Bot +from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes +from django.utils import timezone +from django.conf import settings +from asgiref.sync import sync_to_async +from .models import BotSettings, TelegramMessage, AccessRequest +from .localization import get_localized_message, get_localized_button, get_user_language, MessageLocalizer + +logger = logging.getLogger(__name__) + + +class TelegramBotManager: + """Singleton manager for Telegram bot with file locking""" + _instance = None + _lock = threading.Lock() + _bot_thread: Optional[threading.Thread] = None + _application: Optional[Application] = None + _stop_event = threading.Event() + _lockfile = None + + def __new__(cls): + if cls._instance is None: + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + # Initialize only once + if not hasattr(self, '_initialized'): + self._initialized = True + self._running = False + self._lockfile = None + # Sync status on startup + self._sync_status_on_startup() + + def _acquire_lock(self): + """Acquire file lock to prevent multiple bot instances""" + try: + # Create lock file path + lock_dir = os.path.join(getattr(settings, 'BASE_DIR', '/tmp'), 'telegram_bot_locks') + os.makedirs(lock_dir, exist_ok=True) + lock_path = os.path.join(lock_dir, 'telegram_bot.lock') + + # Open lock file + self._lockfile = open(lock_path, 'w') + + # Try to acquire exclusive lock (non-blocking) + fcntl.flock(self._lockfile.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + + # Write PID to lock file + self._lockfile.write(f"{os.getpid()}\n") + self._lockfile.flush() + + logger.info(f"Acquired bot lock: {lock_path}") + return True + + except (OSError, IOError) as e: + if self._lockfile: + try: + self._lockfile.close() + except: + pass + self._lockfile = None + + logger.warning(f"Could not acquire bot lock: {e}") + return False + + def _release_lock(self): + """Release file lock""" + if self._lockfile: + try: + fcntl.flock(self._lockfile.fileno(), fcntl.LOCK_UN) + self._lockfile.close() + logger.info("Released bot lock") + except: + pass + finally: + self._lockfile = None + + def start(self): + """Start the bot in a background thread""" + if self._running: + logger.warning("Bot is already running") + return + + # Try to acquire lock first + if not self._acquire_lock(): + raise Exception("Another bot instance is already running (could not acquire lock)") + + # No database status to reset - only lock file matters + + bot_settings = BotSettings.get_settings() + if not bot_settings.enabled: + self._release_lock() + raise Exception("Bot is disabled in settings") + + if not bot_settings.bot_token: + self._release_lock() + raise Exception("Bot token is not configured") + + try: + self._stop_event.clear() + self._bot_thread = threading.Thread(target=self._run_bot, daemon=True) + self._bot_thread.start() + + # Wait a moment to see if thread starts successfully + time.sleep(0.5) + + # Check if thread started successfully + if self._bot_thread.is_alive(): + logger.info("Bot started successfully") + else: + self._release_lock() + raise Exception("Bot thread failed to start") + + except Exception as e: + self._release_lock() + raise e + + def stop(self): + """Stop the bot""" + if not self._running: + logger.warning("Bot is not running") + return + + logger.info("Stopping bot...") + self._stop_event.set() + + if self._application: + # Stop the application + try: + self._application.stop_running() + except Exception as e: + logger.error(f"Error stopping application: {e}") + + # Wait for thread to finish + if self._bot_thread and self._bot_thread.is_alive(): + self._bot_thread.join(timeout=10) + + self._running = False + + # Release file lock + self._release_lock() + + logger.info("Bot stopped") + + def restart(self): + """Restart the bot""" + logger.info("Restarting bot...") + self.stop() + time.sleep(2) # Wait a bit before restarting + self.start() + + def _run_bot(self): + """Run the bot (in background thread with asyncio loop)""" + try: + # Create new event loop for this thread + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + # Run the async bot + loop.run_until_complete(self._async_run_bot()) + + except Exception as e: + logger.error(f"Bot error: {e}") + self._running = False + # Release lock on error + self._release_lock() + finally: + try: + loop.close() + except: + pass + + async def _async_run_bot(self): + """Async bot runner""" + try: + self._running = True + settings = await sync_to_async(BotSettings.get_settings)() + + # Create application with custom request settings + from telegram.request import HTTPXRequest + + # Prepare request settings + request_kwargs = { + 'connection_pool_size': 8, + 'read_timeout': settings.connection_timeout, + 'write_timeout': settings.connection_timeout, + 'connect_timeout': settings.connection_timeout, + 'pool_timeout': settings.connection_timeout + } + + # Add proxy if configured + if settings.use_proxy and settings.proxy_url: + logger.info(f"Using proxy: {settings.proxy_url}") + request_kwargs['proxy'] = settings.proxy_url + + request = HTTPXRequest(**request_kwargs) + + # Create application builder + app_builder = Application.builder().token(settings.bot_token).request(request) + + # Use custom API base URL if provided + if settings.api_base_url and settings.api_base_url != "https://api.telegram.org": + logger.info(f"Using custom API base URL: {settings.api_base_url}") + app_builder = app_builder.base_url(settings.api_base_url) + + self._application = app_builder.build() + + # Add handlers + self._application.add_handler(CommandHandler("start", self._handle_start)) + self._application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self._handle_message)) + self._application.add_handler(MessageHandler(filters.ALL & ~filters.COMMAND, self._handle_other)) + + # Initialize application + await self._application.initialize() + await self._application.start() + await self._application.updater.start_polling( + allowed_updates=Update.ALL_TYPES, + drop_pending_updates=True + ) + + logger.info("Bot polling started successfully") + + # Test connection + try: + logger.info("Testing bot connection...") + bot = self._application.bot + me = await bot.get_me() + logger.info(f"Bot connected successfully. Bot info: @{me.username} ({me.first_name})") + except Exception as test_e: + logger.error(f"Bot connection test failed: {test_e}") + logger.error(f"Connection settings - API URL: {settings.api_base_url}, Timeout: {settings.connection_timeout}s") + if settings.use_proxy: + logger.error(f"Proxy settings - URL: {settings.proxy_url}") + raise + + # Keep running until stop event is set + while not self._stop_event.is_set(): + await asyncio.sleep(1) + + logger.info("Stop event received, shutting down...") + + except Exception as e: + logger.error(f"Async bot error: {e}") + raise + finally: + # Clean shutdown + if self._application: + try: + await self._application.updater.stop() + await self._application.stop() + await self._application.shutdown() + except Exception as e: + logger.error(f"Error during shutdown: {e}") + self._running = False + # Release lock on shutdown + self._release_lock() + + async def _handle_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /start command""" + try: + # Save incoming message + saved_message = await self._save_message(update.message, 'incoming') + + # Check if user exists by telegram_user_id + user_response = await self._check_user_access(update.message.from_user) + + if user_response['action'] == 'existing_user': + # User already exists - show keyboard with options + from telegram import ReplyKeyboardMarkup, KeyboardButton + + # Create keyboard for registered users with localized buttons + access_button = get_localized_button(update.message.from_user, 'access') + keyboard = [ + [KeyboardButton(access_button)], + ] + reply_markup = ReplyKeyboardMarkup( + keyboard, + resize_keyboard=True, + one_time_keyboard=False + ) + + help_text = get_localized_message(update.message.from_user, 'help_text') + sent_message = await update.message.reply_text( + help_text, + reply_markup=reply_markup + ) + logger.info(f"Handled /start from existing user {user_response['user'].username}") + + elif user_response['action'] == 'show_new_user_keyboard': + # Show keyboard with request access button for new users + await self._show_new_user_keyboard(update) + + elif user_response['action'] == 'pending_request': + # Show pending request message + await self._show_pending_request_message(update) + else: + # Fallback case - show new user keyboard + await self._show_new_user_keyboard(update) + + # Save outgoing message (if sent_message was created) + if 'sent_message' in locals(): + await self._save_outgoing_message( + sent_message, + update.message.from_user + ) + + except Exception as e: + logger.error(f"Error handling /start: {e}") + + async def _handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle text messages""" + try: + # Save incoming message + saved_message = await self._save_message(update.message, 'incoming') + + # Check if user exists by telegram_user_id + user_response = await self._check_user_access(update.message.from_user) + + if user_response['action'] == 'existing_user': + # Get localized button texts for comparison + access_btn = get_localized_button(update.message.from_user, 'access') + all_in_one_btn = get_localized_button(update.message.from_user, 'all_in_one') + back_btn = get_localized_button(update.message.from_user, 'back') + group_prefix = get_localized_button(update.message.from_user, 'group_prefix') + + # Check if this is a keyboard command + if update.message.text == access_btn: + await self._handle_access_command(update, user_response['user']) + elif update.message.text.startswith(group_prefix): + # Handle specific group selection + group_name = update.message.text.replace(group_prefix, "") + await self._handle_group_selection(update, user_response['user'], group_name) + elif update.message.text == all_in_one_btn: + # Handle All-in-one selection + await self._handle_all_in_one(update, user_response['user']) + elif update.message.text == back_btn: + # Handle back button + await self._handle_back_to_main(update, user_response['user']) + else: + # Unrecognized command - send help message + await self._send_help_message(update) + return + + elif user_response['action'] == 'show_new_user_keyboard': + # Check if user clicked "Request Access" button + request_access_btn = get_localized_button(update.message.from_user, 'request_access') + + if update.message.text == request_access_btn: + # User clicked request access - create request + await self._create_access_request(update.message.from_user, update.message, saved_message) + request_created_msg = get_localized_message(update.message.from_user, 'access_request_created') + sent_message = await update.message.reply_text(request_created_msg) + + # Save outgoing message + await self._save_outgoing_message(sent_message, update.message.from_user) + logger.info(f"Created access request for user {update.message.from_user.username or update.message.from_user.id}") + else: + # User sent other text - show keyboard again + await self._show_new_user_keyboard(update) + return + + elif user_response['action'] == 'pending_request': + # Show pending request message + await self._show_pending_request_message(update) + return + + elif user_response['action'] == 'create_request': + # Create new access request with this message + await self._create_access_request(update.message.from_user, update.message, saved_message) + request_created_msg = get_localized_message(update.message.from_user, 'access_request_created') + sent_message = await update.message.reply_text( + request_created_msg + ) + logger.info(f"Created access request for new user {update.message.from_user.username or update.message.from_user.id}") + + # Save outgoing message + await self._save_outgoing_message( + sent_message, + update.message.from_user + ) + + except Exception as e: + logger.error(f"Error handling message: {e}") + + async def _handle_other(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle non-text messages (photos, documents, etc.)""" + try: + # Save incoming message + await self._save_message(update.message, 'incoming') + + # Auto-reply with localized message + message_type = "content" + if update.message.photo: + message_type = "photo" + elif update.message.document: + message_type = "document" + elif update.message.voice: + message_type = "voice" + elif update.message.video: + message_type = "video" + + # Get localized content type name + language = get_user_language(update.message.from_user) + localized_type = MessageLocalizer.get_content_type_name(message_type, language) + + reply_text = get_localized_message( + update.message.from_user, + 'received_content', + message_type=localized_type + ) + sent_message = await update.message.reply_text(reply_text) + + # Save outgoing message + await self._save_outgoing_message( + sent_message, + update.message.from_user + ) + + logger.info(f"Handled {message_type} from {update.message.from_user.username or update.message.from_user.id}") + + except Exception as e: + logger.error(f"Error handling non-text message: {e}") + + async def _save_message(self, message, direction='incoming'): + """Save message to database""" + try: + # Prepare message text + message_text = message.text or message.caption or "" + + # Handle different message types + if message.photo: + message_text = f"[Photo] {message_text}" + elif message.document: + message_text = f"[Document: {message.document.file_name}] {message_text}" + elif message.voice: + message_text = f"[Voice message] {message_text}" + elif message.video: + message_text = f"[Video] {message_text}" + elif message.sticker: + message_text = f"[Sticker: {message.sticker.emoji or 'no emoji'}]" + + # Convert message to dict for raw_data + raw_data = message.to_dict() if hasattr(message, 'to_dict') else {} + + # Get user language and create database record + user_language = get_user_language(message.from_user) + telegram_message = await sync_to_async(TelegramMessage.objects.create)( + direction=direction, + telegram_user_id=message.from_user.id, + telegram_username=message.from_user.username or "", + telegram_first_name=message.from_user.first_name or "", + telegram_last_name=message.from_user.last_name or "", + chat_id=message.chat_id, + message_id=message.message_id, + message_text=message_text, + raw_data=raw_data, + user_language=user_language + ) + + logger.debug(f"Saved {direction} message from {message.from_user.id}") + return telegram_message + + except Exception as e: + logger.error(f"Error saving message: {e}") + return None + + async def _save_outgoing_message(self, message, to_user): + """Save outgoing message to database""" + try: + raw_data = message.to_dict() if hasattr(message, 'to_dict') else {} + user_language = get_user_language(to_user) + + await sync_to_async(TelegramMessage.objects.create)( + direction='outgoing', + telegram_user_id=to_user.id, + telegram_username=to_user.username or "", + telegram_first_name=to_user.first_name or "", + telegram_last_name=to_user.last_name or "", + chat_id=message.chat_id, + message_id=message.message_id, + message_text=message.text, + raw_data=raw_data, + user_language=user_language + ) + + logger.debug(f"Saved outgoing message to {to_user.id}") + + except Exception as e: + logger.error(f"Error saving outgoing message: {e}") + + async def _check_user_access(self, telegram_user): + """Check if user exists or has pending request, or can be linked by username""" + from vpn.models import User + + try: + # Check if user already exists by telegram_user_id + user = await sync_to_async(User.objects.filter(telegram_user_id=telegram_user.id).first)() + if user: + return {'action': 'existing_user', 'user': user} + + # Check if user can be linked by telegram username + if telegram_user.username: + # Look for existing user with matching telegram_username (case-insensitive) + existing_user_by_username = await sync_to_async( + User.objects.filter( + telegram_username__iexact=telegram_user.username, + telegram_user_id__isnull=True # Not yet linked to Telegram + ).first + )() + + if existing_user_by_username: + # Link this telegram account to existing user + await self._link_telegram_to_user(existing_user_by_username, telegram_user) + return {'action': 'existing_user', 'user': existing_user_by_username} + + # Check if this telegram_user_id was previously linked to a user but is now unlinked + # This handles the case where an admin "untied" the user from Telegram + unlinked_user = await sync_to_async( + User.objects.filter( + telegram_username__iexact=telegram_user.username if telegram_user.username else '', + telegram_user_id__isnull=True + ).first + )() if telegram_user.username else None + + # Check if user has pending access request + existing_request = await sync_to_async( + AccessRequest.objects.filter(telegram_user_id=telegram_user.id).first + )() + + if existing_request: + if existing_request.approved: + # Check if there was an approved request but user was unlinked + # In this case, allow creating a new request (treat as new user) + if unlinked_user: + # Delete old request since user was unlinked + await sync_to_async(existing_request.delete)() + logger.info(f"Deleted old approved request for unlinked user @{telegram_user.username}") + return {'action': 'show_new_user_keyboard'} + else: + # Request approved but user not created yet (shouldn't happen but handle gracefully) + return {'action': 'show_new_user_keyboard'} + else: + # Check if user was unlinked after making the request + if unlinked_user: + # Delete old pending request and allow new one + await sync_to_async(existing_request.delete)() + logger.info(f"Deleted old pending request for unlinked user @{telegram_user.username}") + return {'action': 'show_new_user_keyboard'} + else: + # Request pending + return {'action': 'pending_request'} + + # No user and no request - new user + return {'action': 'show_new_user_keyboard'} + + except Exception as e: + logger.error(f"Error checking user access: {e}") + # Default to new user keyboard if check fails + return {'action': 'show_new_user_keyboard'} + + async def _handle_access_command(self, update: Update, user): + """Handle Access button - show subscription groups keyboard""" + try: + # Import Xray models + from vpn.models_xray import UserSubscription + from telegram import ReplyKeyboardMarkup, KeyboardButton + + # Get user's active subscription groups + subscriptions = await sync_to_async( + lambda: list( + UserSubscription.objects.filter( + user=user, + active=True, + subscription_group__is_active=True + ).select_related('subscription_group') + ) + )() + + if subscriptions: + # Create keyboard with subscription groups using localized buttons + keyboard = [] + + # Get localized button texts + all_in_one_btn = get_localized_button(update.message.from_user, 'all_in_one') + group_prefix = get_localized_button(update.message.from_user, 'group_prefix') + back_btn = get_localized_button(update.message.from_user, 'back') + + # Add All-in-one button first + keyboard.append([KeyboardButton(all_in_one_btn)]) + + # Add individual group buttons in 2 columns + group_buttons = [] + for sub in subscriptions: + group_name = sub.subscription_group.name + group_buttons.append(KeyboardButton(f"{group_prefix}{group_name}")) + + # Arrange buttons in 2 columns + for i in range(0, len(group_buttons), 2): + if i + 1 < len(group_buttons): + # Two buttons in a row + keyboard.append([group_buttons[i], group_buttons[i + 1]]) + else: + # One button in the last row + keyboard.append([group_buttons[i]]) + + # Add back button + keyboard.append([KeyboardButton(back_btn)]) + + reply_markup = ReplyKeyboardMarkup( + keyboard, + resize_keyboard=True, + one_time_keyboard=False + ) + + # Send message with keyboard using localized text + choose_text = get_localized_message(update.message.from_user, 'choose_subscription') + all_in_one_desc = get_localized_message(update.message.from_user, 'all_in_one_desc') + group_desc = get_localized_message(update.message.from_user, 'group_desc') + select_option = get_localized_message(update.message.from_user, 'select_option') + + message_text = f"{choose_text}\n\n" + message_text += f"{all_in_one_desc}\n" + message_text += f"{group_desc}\n\n" + message_text += select_option + + sent_message = await update.message.reply_text( + message_text, + reply_markup=reply_markup, + parse_mode='Markdown' + ) + else: + # No active subscriptions - show main keyboard + access_button = get_localized_button(update.message.from_user, 'access') + keyboard = [ + [KeyboardButton(access_button)], + ] + reply_markup = ReplyKeyboardMarkup( + keyboard, + resize_keyboard=True, + one_time_keyboard=False + ) + + no_subs_msg = get_localized_message(update.message.from_user, 'no_subscriptions') + sent_message = await update.message.reply_text( + no_subs_msg, + reply_markup=reply_markup + ) + + # Save outgoing message + await self._save_outgoing_message( + sent_message, + update.message.from_user + ) + + logger.info(f"Showed subscription groups keyboard to user {user.username}") + + except Exception as e: + logger.error(f"Error handling Access command: {e}") + error_msg = get_localized_message(update.message.from_user, 'error_loading_subscriptions') + await update.message.reply_text(error_msg) + + async def _create_access_request(self, telegram_user, message, saved_message=None): + """Create new access request""" + try: + # Check if request already exists (safety check) + existing = await sync_to_async( + AccessRequest.objects.filter(telegram_user_id=telegram_user.id).first + )() + + if existing and not existing.approved: + logger.warning(f"Access request already exists for user {telegram_user.id}") + return existing + + # Create new request with default username from Telegram and user language + default_username = telegram_user.username or "" + user_language = get_user_language(telegram_user) + request = await sync_to_async(AccessRequest.objects.create)( + telegram_user_id=telegram_user.id, + telegram_username=telegram_user.username or "", + telegram_first_name=telegram_user.first_name or "", + telegram_last_name=telegram_user.last_name or "", + message_text=message.text or message.caption or "[Non-text message]", + chat_id=message.chat_id, + first_message=saved_message, + desired_username=default_username, + user_language=user_language + ) + + logger.info(f"Created access request for user {telegram_user.username or telegram_user.id}") + return request + + except Exception as e: + logger.error(f"Error creating access request: {e}") + return None + + async def _send_help_message(self, update: Update): + """Send help message for unrecognized commands""" + try: + # Create main keyboard for existing users with localized buttons + from telegram import ReplyKeyboardMarkup, KeyboardButton + access_button = get_localized_button(update.message.from_user, 'access') + keyboard = [ + [KeyboardButton(access_button)], + ] + reply_markup = ReplyKeyboardMarkup( + keyboard, + resize_keyboard=True, + one_time_keyboard=False + ) + + help_text = get_localized_message(update.message.from_user, 'help_text') + sent_message = await update.message.reply_text( + help_text, + reply_markup=reply_markup + ) + + # Save outgoing message + await self._save_outgoing_message( + sent_message, + update.message.from_user + ) + + except Exception as e: + logger.error(f"Error sending help message: {e}") + + async def _handle_group_selection(self, update: Update, user, group_name): + """Handle specific group selection""" + try: + from django.conf import settings + + # Get the base URL for subscription links from settings + base_url = getattr(settings, 'EXTERNAL_ADDRESS', 'https://your-server.com') + + # Parse base_url to ensure it has https:// scheme + if not base_url.startswith(('http://', 'https://')): + base_url = f"https://{base_url}" + + # Generate group-specific subscription link + subscription_link = f"{base_url}/xray/{user.hash}?group={group_name}" + + # Also generate user portal link + portal_link = f"{base_url}/u/{user.hash}" + + # Build localized message + group_title = get_localized_message(update.message.from_user, 'group_subscription', group_name=group_name) + sub_link_label = get_localized_message(update.message.from_user, 'subscription_link') + portal_label = get_localized_message(update.message.from_user, 'web_portal') + tap_note = get_localized_message(update.message.from_user, 'tap_to_copy') + + message_text = f"{group_title}\n\n" + message_text += f"{sub_link_label}\n" + message_text += f"`{subscription_link}`\n\n" + message_text += f"{portal_label}\n" + message_text += f"{portal_link}\n\n" + message_text += tap_note + + # Create back navigation keyboard with only back button + from telegram import ReplyKeyboardMarkup, KeyboardButton + back_btn = get_localized_button(update.message.from_user, 'back') + keyboard = [ + [KeyboardButton(back_btn)], + ] + reply_markup = ReplyKeyboardMarkup( + keyboard, + resize_keyboard=True, + one_time_keyboard=False + ) + + sent_message = await update.message.reply_text( + message_text, + reply_markup=reply_markup, + parse_mode='Markdown' + ) + + # Save outgoing message + await self._save_outgoing_message( + sent_message, + update.message.from_user + ) + + logger.info(f"Sent group {group_name} subscription to user {user.username}") + + except Exception as e: + logger.error(f"Error handling group selection: {e}") + error_msg = get_localized_message(update.message.from_user, 'error_loading_group') + await update.message.reply_text(error_msg) + + async def _handle_all_in_one(self, update: Update, user): + """Handle All-in-one selection""" + try: + # Import Xray models + from vpn.models_xray import UserSubscription, ServerInbound + from django.conf import settings + + # Get user's active subscription groups + subscriptions = await sync_to_async( + lambda: list( + UserSubscription.objects.filter( + user=user, + active=True, + subscription_group__is_active=True + ).select_related('subscription_group').prefetch_related('subscription_group__inbounds') + ) + )() + + if subscriptions: + # Get the base URL for subscription links from settings + base_url = getattr(settings, 'EXTERNAL_ADDRESS', 'https://your-server.com') + + # Parse base_url to ensure it has https:// scheme + if not base_url.startswith(('http://', 'https://')): + base_url = f"https://{base_url}" + + # Generate universal subscription link + subscription_link = f"{base_url}/xray/{user.hash}" + + # Also generate user portal link + portal_link = f"{base_url}/u/{user.hash}" + + # Build message with detailed server/subscription list using localized text + all_in_one_title = get_localized_message(update.message.from_user, 'all_in_one_subscription') + access_includes = get_localized_message(update.message.from_user, 'your_access_includes') + message_text = f"{all_in_one_title}\n\n" + message_text += f"{access_includes}\n\n" + + # Collect all servers and their subscriptions + servers_data = {} + + for sub in subscriptions: + group = sub.subscription_group + group_name = group.name + + # Get servers where this group's inbounds are deployed + deployed_servers = await sync_to_async( + lambda: list( + ServerInbound.objects.filter( + inbound__in=group.inbounds.all(), + active=True + ).select_related('server', 'inbound').values_list( + 'server__name', 'inbound__name', 'inbound__protocol' + ).distinct() + ) + )() + + # Group by server + for server_name, inbound_name, protocol in deployed_servers: + if server_name not in servers_data: + servers_data[server_name] = [] + servers_data[server_name].append({ + 'group_name': group_name, + 'inbound_name': inbound_name, + 'protocol': protocol.upper() + }) + + # Display servers and their subscriptions + for server_name, server_subscriptions in servers_data.items(): + message_text += f"πŸ”’ **{server_name}**\n" + + for sub_data in server_subscriptions: + message_text += f" β€’ {sub_data['group_name']} ({sub_data['protocol']})\n" + + message_text += "\n" + + # Add subscription link with localized labels + universal_link_label = get_localized_message(update.message.from_user, 'universal_subscription_link') + portal_label = get_localized_message(update.message.from_user, 'web_portal') + all_subs_note = get_localized_message(update.message.from_user, 'all_subscriptions_note') + + message_text += f"{universal_link_label}\n" + message_text += f"`{subscription_link}`\n\n" + + # Add portal link + message_text += f"{portal_label}\n" + message_text += f"{portal_link}\n\n" + + message_text += all_subs_note + + # Create back navigation keyboard with only back button + from telegram import ReplyKeyboardMarkup, KeyboardButton + back_btn = get_localized_button(update.message.from_user, 'back') + keyboard = [ + [KeyboardButton(back_btn)], + ] + reply_markup = ReplyKeyboardMarkup( + keyboard, + resize_keyboard=True, + one_time_keyboard=False + ) + + sent_message = await update.message.reply_text( + message_text, + reply_markup=reply_markup, + parse_mode='Markdown' + ) + else: + # No active subscriptions + no_subs_msg = get_localized_message(update.message.from_user, 'no_subscriptions') + sent_message = await update.message.reply_text(no_subs_msg) + + # Save outgoing message + await self._save_outgoing_message( + sent_message, + update.message.from_user + ) + + logger.info(f"Sent all-in-one subscription to user {user.username}") + + except Exception as e: + logger.error(f"Error handling all-in-one selection: {e}") + error_msg = get_localized_message(update.message.from_user, 'error_loading_subscriptions') + await update.message.reply_text(error_msg) + + async def _handle_back_to_main(self, update: Update, user): + """Handle back button - return to main menu""" + try: + from telegram import ReplyKeyboardMarkup, KeyboardButton + + # Create main keyboard with localized buttons + access_btn = get_localized_button(update.message.from_user, 'access') + keyboard = [ + [KeyboardButton(access_btn)], + ] + reply_markup = ReplyKeyboardMarkup( + keyboard, + resize_keyboard=True, + one_time_keyboard=False + ) + + help_text = get_localized_message(update.message.from_user, 'help_text') + sent_message = await update.message.reply_text( + help_text, + reply_markup=reply_markup + ) + + # Save outgoing message + await self._save_outgoing_message( + sent_message, + update.message.from_user + ) + + logger.info(f"Returned user {user.username} to main menu") + + except Exception as e: + logger.error(f"Error handling back button: {e}") + await self._send_help_message(update) + + async def _show_new_user_keyboard(self, update: Update): + """Show keyboard with request access button for new users""" + try: + from telegram import ReplyKeyboardMarkup, KeyboardButton + + # Get localized button and message + request_access_btn = get_localized_button(update.message.from_user, 'request_access') + welcome_msg = get_localized_message(update.message.from_user, 'new_user_welcome') + + # Create keyboard with request access button + keyboard = [ + [KeyboardButton(request_access_btn)], + ] + reply_markup = ReplyKeyboardMarkup( + keyboard, + resize_keyboard=True, + one_time_keyboard=False + ) + + sent_message = await update.message.reply_text( + welcome_msg, + reply_markup=reply_markup + ) + + # Save outgoing message + await self._save_outgoing_message( + sent_message, + update.message.from_user + ) + + logger.info(f"Showed new user keyboard to {update.message.from_user.username or update.message.from_user.id}") + + except Exception as e: + logger.error(f"Error showing new user keyboard: {e}") + + async def _show_pending_request_message(self, update: Update, custom_message: str = None): + """Show pending request message to users with pending access requests""" + try: + # Use custom message or default pending message + if custom_message: + message_text = custom_message + else: + message_text = get_localized_message(update.message.from_user, 'pending_request_msg') + + sent_message = await update.message.reply_text(message_text) + + # Save outgoing message + await self._save_outgoing_message( + sent_message, + update.message.from_user + ) + + logger.info(f"Showed pending request message to {update.message.from_user.username or update.message.from_user.id}") + + except Exception as e: + logger.error(f"Error showing pending request message: {e}") + + async def _link_telegram_to_user(self, user, telegram_user): + """Link Telegram account to existing VPN user""" + try: + user.telegram_user_id = telegram_user.id + user.telegram_username = telegram_user.username or "" + user.telegram_first_name = telegram_user.first_name or "" + user.telegram_last_name = telegram_user.last_name or "" + + await sync_to_async(user.save)() + + logger.info(f"Linked Telegram user @{telegram_user.username} (ID: {telegram_user.id}) to existing VPN user {user.username}") + + except Exception as e: + logger.error(f"Error linking Telegram to user: {e}") + raise + + def _sync_status_on_startup(self): + """No database status to sync - only lock file matters""" + pass + + def auto_start_if_enabled(self): + """Auto-start bot if enabled in settings""" + try: + bot_settings = BotSettings.get_settings() + if bot_settings.enabled and bot_settings.bot_token and not self._running: + logger.info("Auto-starting bot (enabled in settings)") + self.start() + return True + else: + if not bot_settings.enabled: + logger.info("Bot auto-start skipped: disabled in settings") + elif not bot_settings.bot_token: + logger.info("Bot auto-start skipped: no token configured") + elif self._running: + logger.info("Bot auto-start skipped: already running") + except Exception as e: + # Don't log as error if it's just a lock conflict + if "could not acquire lock" in str(e).lower(): + logger.info(f"Bot auto-start skipped: {e}") + else: + logger.error(f"Failed to auto-start bot: {e}") + return False + + @property + def is_running(self): + """Check if bot is running""" + return self._running and self._bot_thread and self._bot_thread.is_alive() diff --git a/telegram_bot/localization.py b/telegram_bot/localization.py new file mode 100644 index 0000000..0f32021 --- /dev/null +++ b/telegram_bot/localization.py @@ -0,0 +1,207 @@ +""" +Message localization for Telegram bot +""" +from typing import Optional, Dict, Any +import logging + +logger = logging.getLogger(__name__) + +# Translation dictionaries +MESSAGES = { + 'en': { + 'help_text': "πŸ“‹ Welcome! Use buttons below to navigate.\n\nπŸ“Š Access - View your VPN subscriptions\n\nFor support contact administrator.", + 'access_request_created': "Access request created, please wait.", + 'new_user_welcome': "Welcome! To get access to VPN services, please request access using the button below.", + 'pending_request_msg': "Your access request is pending approval. Please wait for administrator to review it.", + 'choose_subscription': "**Choose subscription option:**", + 'all_in_one_desc': "🌍 **All-in-one** - Get all subscriptions in one link", + 'group_desc': "**Group** - Get specific group subscription", + 'select_option': "Select an option below:", + 'no_subscriptions': "❌ You don't have any active Xray subscriptions.\n\nPlease contact administrator for access.", + 'group_subscription': "**Group: {group_name}**", + 'subscription_link': "**πŸ”— Subscription Link:**", + 'web_portal': "**🌐 Web Portal:**", + 'tap_to_copy': "_Tap the subscription link to copy it. Use it in your Xray client._", + 'all_in_one_subscription': "🌍 **All-in-one Subscription**", + 'your_access_includes': "**Your Access Includes:**", + 'universal_subscription_link': "**πŸ”— Universal Subscription Link:**", + 'all_subscriptions_note': "_This link includes all your active subscriptions. Tap to copy._", + 'error_loading_subscriptions': "❌ Error loading subscriptions. Please try again later.", + 'error_loading_group': "❌ Error loading group subscription. Please try again later.", + 'received_content': "Received your {message_type}. An administrator will review it.", + 'approval_notification': "βœ… Access approved!", + 'content_types': { + 'photo': 'photo', + 'document': 'document', + 'voice': 'voice', + 'video': 'video', + 'content': 'content' + }, + 'buttons': { + 'access': "🌍 Get access", + 'all_in_one': "🌍 All-in-one", + 'back': "⬅️ Back", + 'group_prefix': "Group: ", + 'request_access': "πŸ”‘ Request Access" + } + }, + 'ru': { + 'help_text': "πŸ“‹ Π”ΠΎΠ±Ρ€ΠΎ ΠΏΠΎΠΆΠ°Π»ΠΎΠ²Π°Ρ‚ΡŒ! Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠΉΡ‚Π΅ ΠΊΠ½ΠΎΠΏΠΊΠΈ Π½ΠΈΠΆΠ΅ для Π½Π°Π²ΠΈΠ³Π°Ρ†ΠΈΠΈ.\n\nπŸ“Š Доступ - ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€ VPN подписок\n\nДля ΠΏΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΊΠΈ ΠΎΠ±Ρ€Π°Ρ‚ΠΈΡ‚Π΅ΡΡŒ ΠΊ администратору.", + 'access_request_created': "Запрос Π½Π° доступ создан, ΠΎΠΆΠΈΠ΄Π°ΠΉΡ‚Π΅.", + 'new_user_welcome': "Π”ΠΎΠ±Ρ€ΠΎ ΠΏΠΎΠΆΠ°Π»ΠΎΠ²Π°Ρ‚ΡŒ! Для получСния доступа ΠΊ VPN сСрвисам, поТалуйста запроситС доступ с ΠΏΠΎΠΌΠΎΡ‰ΡŒΡŽ ΠΊΠ½ΠΎΠΏΠΊΠΈ Π½ΠΈΠΆΠ΅.", + 'pending_request_msg': "Π’Π°Ρˆ запрос Π½Π° доступ ΠΎΠΆΠΈΠ΄Π°Π΅Ρ‚ одобрСния. ΠŸΠΎΠΆΠ°Π»ΡƒΠΉΡΡ‚Π°, Π΄ΠΎΠΆΠ΄ΠΈΡ‚Π΅ΡΡŒ рассмотрСния администратором.", + 'choose_subscription': "**Π’Ρ‹Π±Π΅Ρ€ΠΈΡ‚Π΅ Π²Π°Ρ€ΠΈΠ°Π½Ρ‚ подписки:**", + 'all_in_one_desc': "🌍 **ВсС Π² ΠΎΠ΄Π½ΠΎΠΌ** - ΠŸΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ всС подписки Π² ΠΎΠ΄Π½ΠΎΠΉ ссылкС", + 'group_desc': "**Π“Ρ€ΡƒΠΏΠΏΠ°** - ΠŸΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ подписку Π½Π° Π³Ρ€ΡƒΠΏΠΏΡƒ", + 'select_option': "Π’Ρ‹Π±Π΅Ρ€ΠΈΡ‚Π΅ Π²Π°Ρ€ΠΈΠ°Π½Ρ‚ Π½ΠΈΠΆΠ΅:", + 'no_subscriptions': "❌ Π£ вас Π½Π΅Ρ‚ Π°ΠΊΡ‚ΠΈΠ²Π½Ρ‹Ρ… Xray подписок.\n\nΠžΠ±Ρ€Π°Ρ‚ΠΈΡ‚Π΅ΡΡŒ ΠΊ администратору для получСния доступа.", + 'group_subscription': "**Π“Ρ€ΡƒΠΏΠΏΠ°: {group_name}**", + 'subscription_link': "**πŸ”— **", + 'web_portal': "**🌐 Π’Π΅Π±-ΠΏΠΎΡ€Ρ‚Π°Π» ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ:**", + 'tap_to_copy': "_НаТмитС Π½Π° ссылку Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΡΠΊΠΎΠΏΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ. Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠΉΡ‚Π΅ Π² вашСм Xray ΠΊΠ»ΠΈΠ΅Π½Ρ‚Π΅ ΠΊΠ°ΠΊ подписку._", + 'all_in_one_subscription': "🌍 **Подписка «ВсС Π² ΠΎΠ΄Π½ΠΎΠΌΒ»**", + 'your_access_includes': "**Π’Π°Ρˆ доступ Π²ΠΊΠ»ΡŽΡ‡Π°Π΅Ρ‚:**", + 'universal_subscription_link': "**πŸ”— Π£Π½ΠΈΠ²Π΅Ρ€ΡΠ°Π»ΡŒΠ½Π°Ρ ссылка Π½Π° подписку:**", + 'all_subscriptions_note': "_Π­Ρ‚Π° ссылка Π²ΠΊΠ»ΡŽΡ‡Π°Π΅Ρ‚ всС ваши Π°ΠΊΡ‚ΠΈΠ²Π½Ρ‹Π΅ подписки. НаТмитС Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΡΠΊΠΎΠΏΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ._", + 'error_loading_subscriptions': "❌ Ошибка Π·Π°Π³Ρ€ΡƒΠ·ΠΊΠΈ подписок. ΠŸΠΎΠΏΡ€ΠΎΠ±ΡƒΠΉΡ‚Π΅ ΠΏΠΎΠ·ΠΆΠ΅.", + 'error_loading_group': "❌ Ошибка Π·Π°Π³Ρ€ΡƒΠ·ΠΊΠΈ подписки Π³Ρ€ΡƒΠΏΠΏΡ‹. ΠŸΠΎΠΏΡ€ΠΎΠ±ΡƒΠΉΡ‚Π΅ ΠΏΠΎΠ·ΠΆΠ΅.", + 'received_content': "ΠŸΠΎΠ»ΡƒΡ‡Π΅Π½ ваш {message_type}. Администратор Π΅Π³ΠΎ рассмотрит.", + 'approval_notification': "βœ… Доступ ΠΎΠ΄ΠΎΠ±Ρ€Π΅Π½!", + 'content_types': { + 'photo': 'Ρ„ΠΎΡ‚ΠΎ', + 'document': 'Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚', + 'voice': 'голосовоС сообщСниС', + 'video': 'Π²ΠΈΠ΄Π΅ΠΎ', + 'content': 'ΠΊΠΎΠ½Ρ‚Π΅Π½Ρ‚' + }, + 'buttons': { + 'access': "🌍 ΠŸΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ VPN", + 'all_in_one': "🌍 ВсС Π² ΠΎΠ΄Π½ΠΎΠΌ", + 'back': "⬅️ Назад", + 'group_prefix': "Π“Ρ€ΡƒΠΏΠΏΠ°: ", + 'request_access': "πŸ”‘ Π—Π°ΠΏΡ€ΠΎΡΠΈΡ‚ΡŒ доступ" + } + } +} + +class MessageLocalizer: + """Class for bot message localization""" + + @staticmethod + def get_user_language(telegram_user) -> str: + """ + Determines user language from Telegram language_code + + Args: + telegram_user: Telegram user object + + Returns: + str: Language code ('ru' or 'en') + """ + if not telegram_user: + return 'en' + + language_code = getattr(telegram_user, 'language_code', None) + + if not language_code: + return 'en' + + # Support Russian and English + if language_code.startswith('ru'): + return 'ru' + else: + return 'en' + + @staticmethod + def get_message(key: str, language: str = 'en', **kwargs) -> str: + """ + Gets localized message + + Args: + key: Message key + language: Language code + **kwargs: Formatting parameters + + Returns: + str: Localized message + """ + try: + # Fallback to English if language not supported + if language not in MESSAGES: + language = 'en' + + message = MESSAGES[language].get(key, MESSAGES['en'].get(key, f"Missing translation: {key}")) + + # Format with parameters + if kwargs: + try: + message = message.format(**kwargs) + except (KeyError, ValueError) as e: + logger.warning(f"Error formatting message {key}: {e}") + + return message + + except Exception as e: + logger.error(f"Error getting message {key} for language {language}: {e}") + return f"Error: {key}" + + @staticmethod + def get_button_text(button_key: str, language: str = 'en') -> str: + """ + Gets button text + + Args: + button_key: Button key + language: Language code + + Returns: + str: Button text + """ + try: + if language not in MESSAGES: + language = 'en' + + buttons = MESSAGES[language].get('buttons', {}) + return buttons.get(button_key, MESSAGES['en']['buttons'].get(button_key, button_key)) + + except Exception as e: + logger.error(f"Error getting button text {button_key} for language {language}: {e}") + return button_key + + @staticmethod + def get_content_type_name(content_type: str, language: str = 'en') -> str: + """ + Gets localized content type name + + Args: + content_type: Content type + language: Language code + + Returns: + str: Localized name + """ + try: + if language not in MESSAGES: + language = 'en' + + content_types = MESSAGES[language].get('content_types', {}) + return content_types.get(content_type, content_type) + + except Exception as e: + logger.error(f"Error getting content type {content_type} for language {language}: {e}") + return content_type + +# Convenience functions for use in code +def get_localized_message(telegram_user, message_key: str, **kwargs) -> str: + """Get localized message for user""" + language = MessageLocalizer.get_user_language(telegram_user) + return MessageLocalizer.get_message(message_key, language, **kwargs) + +def get_localized_button(telegram_user, button_key: str) -> str: + """Get localized button text for user""" + language = MessageLocalizer.get_user_language(telegram_user) + return MessageLocalizer.get_button_text(button_key, language) + +def get_user_language(telegram_user) -> str: + """Get user language""" + return MessageLocalizer.get_user_language(telegram_user) \ No newline at end of file diff --git a/telegram_bot/management/__init__.py b/telegram_bot/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/telegram_bot/management/commands/__init__.py b/telegram_bot/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/telegram_bot/management/commands/run_telegram_bot.py b/telegram_bot/management/commands/run_telegram_bot.py new file mode 100644 index 0000000..5356950 --- /dev/null +++ b/telegram_bot/management/commands/run_telegram_bot.py @@ -0,0 +1,99 @@ +import logging +import signal +import sys +import time +from django.core.management.base import BaseCommand +from django.utils import timezone +from telegram_bot.models import BotSettings, BotStatus +from telegram_bot.bot import TelegramBotManager + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = 'Run the Telegram bot' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.bot_manager = None + self.running = False + + def add_arguments(self, parser): + parser.add_argument( + '--force', + action='store_true', + help='Force start even if bot is disabled in settings', + ) + + def handle(self, *args, **options): + """Main command handler""" + # Set up signal handlers + signal.signal(signal.SIGINT, self.signal_handler) + signal.signal(signal.SIGTERM, self.signal_handler) + + # Check settings + settings = BotSettings.get_settings() + + if not settings.bot_token: + self.stdout.write( + self.style.ERROR('Bot token is not configured. Please configure it in the admin panel.') + ) + return + + if not settings.enabled and not options['force']: + self.stdout.write( + self.style.WARNING('Bot is disabled in settings. Use --force to override.') + ) + return + + # Initialize bot manager + self.bot_manager = TelegramBotManager() + + try: + # Start the bot + self.stdout.write(self.style.SUCCESS('Starting Telegram bot...')) + self.bot_manager.start() + self.running = True + + self.stdout.write( + self.style.SUCCESS(f'Bot is running. Press Ctrl+C to stop.') + ) + + # Keep the main thread alive + while self.running: + time.sleep(1) + + # Check if bot is still running + if not self.bot_manager.is_running: + self.stdout.write( + self.style.ERROR('Bot stopped unexpectedly. Check logs for errors.') + ) + break + + except KeyboardInterrupt: + self.stdout.write('\nReceived interrupt signal...') + + except Exception as e: + self.stdout.write( + self.style.ERROR(f'Error running bot: {e}') + ) + logger.error(f'Error running bot: {e}', exc_info=True) + + # Update status + status = BotStatus.get_status() + status.is_running = False + status.last_error = str(e) + status.last_stopped = timezone.now() + status.save() + + finally: + # Stop the bot + if self.bot_manager: + self.stdout.write('Stopping bot...') + self.bot_manager.stop() + self.stdout.write(self.style.SUCCESS('Bot stopped.')) + + def signal_handler(self, signum, frame): + """Handle shutdown signals""" + self.stdout.write('\nShutting down gracefully...') + self.running = False \ No newline at end of file diff --git a/telegram_bot/management/commands/telegram_bot_status.py b/telegram_bot/management/commands/telegram_bot_status.py new file mode 100644 index 0000000..e91699e --- /dev/null +++ b/telegram_bot/management/commands/telegram_bot_status.py @@ -0,0 +1,112 @@ +import logging +import os +from django.core.management.base import BaseCommand +from django.utils import timezone +from telegram_bot.models import BotSettings, BotStatus +from telegram_bot.bot import TelegramBotManager + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = 'Check Telegram bot status and optionally start it' + + def add_arguments(self, parser): + parser.add_argument( + '--auto-start', + action='store_true', + help='Automatically start bot if enabled in settings', + ) + parser.add_argument( + '--sync-status', + action='store_true', + help='Sync database status with real bot state', + ) + + def handle(self, *args, **options): + """Check bot status""" + try: + manager = TelegramBotManager() + settings = BotSettings.get_settings() + status = BotStatus.get_status() + + # Show current configuration + self.stdout.write(f"Bot Configuration:") + self.stdout.write(f" Enabled: {settings.enabled}") + self.stdout.write(f" Token configured: {'Yes' if settings.bot_token else 'No'}") + + # Show status + real_running = manager.is_running + db_running = status.is_running + + self.stdout.write(f"\nBot Status:") + self.stdout.write(f" Database status: {'Running' if db_running else 'Stopped'}") + self.stdout.write(f" Real status: {'Running' if real_running else 'Stopped'}") + + # Check lock file status + from django.conf import settings as django_settings + lock_dir = os.path.join(getattr(django_settings, 'BASE_DIR', '/tmp'), 'telegram_bot_locks') + lock_path = os.path.join(lock_dir, 'telegram_bot.lock') + + if os.path.exists(lock_path): + try: + with open(lock_path, 'r') as f: + lock_pid = f.read().strip() + self.stdout.write(f" Lock file: exists (PID: {lock_pid})") + except: + self.stdout.write(f" Lock file: exists (unreadable)") + else: + self.stdout.write(f" Lock file: not found") + + if db_running != real_running: + self.stdout.write( + self.style.WARNING("⚠️ Status mismatch detected!") + ) + + if options['sync_status']: + status.is_running = real_running + if not real_running: + status.last_stopped = timezone.now() + status.save() + self.stdout.write( + self.style.SUCCESS("βœ… Status synchronized") + ) + + # Show timestamps + if status.last_started: + self.stdout.write(f" Last started: {status.last_started}") + if status.last_stopped: + self.stdout.write(f" Last stopped: {status.last_stopped}") + if status.last_error: + self.stdout.write(f" Last error: {status.last_error}") + + # Auto-start if requested + if options['auto_start']: + if not real_running and settings.enabled and settings.bot_token: + self.stdout.write("\nAttempting to start bot...") + try: + manager.start() + self.stdout.write( + self.style.SUCCESS("βœ… Bot started successfully") + ) + except Exception as e: + self.stdout.write( + self.style.ERROR(f"❌ Failed to start bot: {e}") + ) + elif real_running: + self.stdout.write( + self.style.SUCCESS("βœ… Bot is already running") + ) + elif not settings.enabled: + self.stdout.write( + self.style.WARNING("⚠️ Bot is disabled in settings") + ) + elif not settings.bot_token: + self.stdout.write( + self.style.ERROR("❌ Bot token not configured") + ) + + except Exception as e: + self.stdout.write( + self.style.ERROR(f"❌ Error checking bot status: {e}") + ) \ No newline at end of file diff --git a/telegram_bot/migrations/0001_initial.py b/telegram_bot/migrations/0001_initial.py new file mode 100644 index 0000000..1702744 --- /dev/null +++ b/telegram_bot/migrations/0001_initial.py @@ -0,0 +1,70 @@ +# Generated by Django 5.1.7 on 2025-08-14 11:18 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='BotSettings', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('bot_token', models.CharField(help_text='Telegram Bot Token from @BotFather', max_length=255)), + ('enabled', models.BooleanField(default=False, help_text='Enable/Disable the bot')), + ('welcome_message', models.TextField(default='Hello! Your message has been received. An administrator will review it.', help_text='Message sent when user starts conversation')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Bot Settings', + 'verbose_name_plural': 'Bot Settings', + }, + ), + migrations.CreateModel( + name='BotStatus', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_running', models.BooleanField(default=False)), + ('last_started', models.DateTimeField(blank=True, null=True)), + ('last_stopped', models.DateTimeField(blank=True, null=True)), + ('last_error', models.TextField(blank=True)), + ('last_update_id', models.BigIntegerField(blank=True, help_text='Last processed update ID from Telegram', null=True)), + ], + options={ + 'verbose_name': 'Bot Status', + 'verbose_name_plural': 'Bot Status', + }, + ), + migrations.CreateModel( + name='TelegramMessage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('direction', models.CharField(choices=[('incoming', 'Incoming'), ('outgoing', 'Outgoing')], db_index=True, max_length=10)), + ('telegram_user_id', models.BigIntegerField(db_index=True)), + ('telegram_username', models.CharField(blank=True, db_index=True, max_length=255, null=True)), + ('telegram_first_name', models.CharField(blank=True, max_length=255, null=True)), + ('telegram_last_name', models.CharField(blank=True, max_length=255, null=True)), + ('chat_id', models.BigIntegerField(db_index=True)), + ('message_id', models.BigIntegerField(blank=True, null=True)), + ('message_text', models.TextField(blank=True)), + ('raw_data', models.JSONField(blank=True, default=dict, help_text='Full message data from Telegram')), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('linked_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='telegram_messages', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Telegram Message', + 'verbose_name_plural': 'Telegram Messages', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['-created_at', 'direction'], name='telegram_bo_created_19b81b_idx'), models.Index(fields=['telegram_user_id', '-created_at'], name='telegram_bo_telegra_f71f27_idx')], + }, + ), + ] diff --git a/telegram_bot/migrations/0002_add_connection_settings.py b/telegram_bot/migrations/0002_add_connection_settings.py new file mode 100644 index 0000000..42e7caf --- /dev/null +++ b/telegram_bot/migrations/0002_add_connection_settings.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.7 on 2025-08-14 12:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('telegram_bot', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='botsettings', + name='api_base_url', + field=models.URLField(blank=True, default='https://api.telegram.org', help_text='Telegram API base URL (change for local bot API server)'), + ), + migrations.AddField( + model_name='botsettings', + name='connection_timeout', + field=models.IntegerField(default=30, help_text='Connection timeout in seconds'), + ), + migrations.AddField( + model_name='botsettings', + name='proxy_url', + field=models.URLField(blank=True, help_text='Proxy URL (e.g., http://proxy:8080 or socks5://proxy:1080)'), + ), + migrations.AddField( + model_name='botsettings', + name='use_proxy', + field=models.BooleanField(default=False, help_text='Enable proxy for Telegram API connections'), + ), + ] diff --git a/telegram_bot/migrations/0003_accessrequest.py b/telegram_bot/migrations/0003_accessrequest.py new file mode 100644 index 0000000..1b76776 --- /dev/null +++ b/telegram_bot/migrations/0003_accessrequest.py @@ -0,0 +1,42 @@ +# Generated by Django 5.1.7 on 2025-08-14 12:24 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('telegram_bot', '0002_add_connection_settings'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='AccessRequest', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('telegram_user_id', models.BigIntegerField(db_index=True, help_text='Telegram user ID who made the request')), + ('telegram_username', models.CharField(blank=True, help_text='Telegram username (without @)', max_length=255, null=True)), + ('telegram_first_name', models.CharField(blank=True, help_text='First name from Telegram', max_length=255, null=True)), + ('telegram_last_name', models.CharField(blank=True, help_text='Last name from Telegram', max_length=255, null=True)), + ('message_text', models.TextField(help_text='The message sent by user when requesting access')), + ('chat_id', models.BigIntegerField(help_text='Telegram chat ID for sending notifications')), + ('status', models.CharField(choices=[('pending', 'Pending'), ('approved', 'Approved'), ('rejected', 'Rejected')], db_index=True, default='pending', max_length=20)), + ('admin_comment', models.TextField(blank=True, help_text='Admin comment for approval/rejection')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('processed_at', models.DateTimeField(blank=True, null=True)), + ('created_user', models.ForeignKey(blank=True, help_text='User created from this request (when approved)', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ('first_message', models.ForeignKey(blank=True, help_text='First message from this user', null=True, on_delete=django.db.models.deletion.SET_NULL, to='telegram_bot.telegrammessage')), + ('processed_by', models.ForeignKey(blank=True, help_text='Admin who processed this request', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='processed_requests', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Access Request', + 'verbose_name_plural': 'Access Requests', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['telegram_user_id'], name='telegram_bo_telegra_e3429d_idx'), models.Index(fields=['status', '-created_at'], name='telegram_bo_status_cf9310_idx'), models.Index(fields=['-created_at'], name='telegram_bo_created_c82a74_idx')], + 'constraints': [models.UniqueConstraint(fields=('telegram_user_id',), name='unique_telegram_user_request')], + }, + ), + ] diff --git a/telegram_bot/migrations/0004_remove_accessrequest_telegram_bo_status_cf9310_idx_and_more.py b/telegram_bot/migrations/0004_remove_accessrequest_telegram_bo_status_cf9310_idx_and_more.py new file mode 100644 index 0000000..e6718c7 --- /dev/null +++ b/telegram_bot/migrations/0004_remove_accessrequest_telegram_bo_status_cf9310_idx_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.7 on 2025-08-14 13:49 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('telegram_bot', '0003_accessrequest'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveIndex( + model_name='accessrequest', + name='telegram_bo_status_cf9310_idx', + ), + migrations.RemoveField( + model_name='accessrequest', + name='status', + ), + migrations.AddField( + model_name='accessrequest', + name='approved', + field=models.BooleanField(db_index=True, default=False, help_text='Request approved by administrator'), + ), + migrations.AlterField( + model_name='accessrequest', + name='admin_comment', + field=models.TextField(blank=True, help_text='Admin comment for approval'), + ), + migrations.AddIndex( + model_name='accessrequest', + index=models.Index(fields=['approved', '-created_at'], name='telegram_bo_approve_7ae92d_idx'), + ), + ] diff --git a/telegram_bot/migrations/0005_delete_botstatus_remove_botsettings_welcome_message_and_more.py b/telegram_bot/migrations/0005_delete_botstatus_remove_botsettings_welcome_message_and_more.py new file mode 100644 index 0000000..2722cc7 --- /dev/null +++ b/telegram_bot/migrations/0005_delete_botstatus_remove_botsettings_welcome_message_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 5.1.7 on 2025-08-14 22:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('telegram_bot', '0004_remove_accessrequest_telegram_bo_status_cf9310_idx_and_more'), + ] + + operations = [ + migrations.DeleteModel( + name='BotStatus', + ), + migrations.RemoveField( + model_name='botsettings', + name='welcome_message', + ), + migrations.AddField( + model_name='botsettings', + name='help_message', + field=models.TextField(default='πŸ“‹ Available commands:\n/start - Start conversation\nπŸ“Š Access - View your VPN subscriptions\n\nFor support contact administrator.', help_text='Help message sent for unrecognized commands'), + ), + ] diff --git a/telegram_bot/migrations/0006_accessrequest_desired_username.py b/telegram_bot/migrations/0006_accessrequest_desired_username.py new file mode 100644 index 0000000..c1afad8 --- /dev/null +++ b/telegram_bot/migrations/0006_accessrequest_desired_username.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.7 on 2025-08-14 22:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('telegram_bot', '0005_delete_botstatus_remove_botsettings_welcome_message_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='accessrequest', + name='desired_username', + field=models.CharField(blank=True, help_text='Desired username for VPN user (defaults to Telegram username)', max_length=150), + ), + ] diff --git a/telegram_bot/migrations/0007_remove_botsettings_help_message_and_more.py b/telegram_bot/migrations/0007_remove_botsettings_help_message_and_more.py new file mode 100644 index 0000000..db9da40 --- /dev/null +++ b/telegram_bot/migrations/0007_remove_botsettings_help_message_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 5.1.7 on 2025-08-14 22:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('telegram_bot', '0006_accessrequest_desired_username'), + ] + + operations = [ + migrations.RemoveField( + model_name='botsettings', + name='help_message', + ), + migrations.AddField( + model_name='accessrequest', + name='user_language', + field=models.CharField(default='en', help_text="User's preferred language (en/ru)", max_length=10), + ), + migrations.AddField( + model_name='telegrammessage', + name='user_language', + field=models.CharField(default='en', help_text="User's preferred language (en/ru)", max_length=10), + ), + ] diff --git a/telegram_bot/migrations/__init__.py b/telegram_bot/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/telegram_bot/models.py b/telegram_bot/models.py new file mode 100644 index 0000000..f00a25d --- /dev/null +++ b/telegram_bot/models.py @@ -0,0 +1,292 @@ +from django.db import models +from django.contrib.auth import get_user_model +from django.utils import timezone +import json + +User = get_user_model() + + +class BotSettings(models.Model): + """Singleton model for bot settings""" + bot_token = models.CharField( + max_length=255, + help_text="Telegram Bot Token from @BotFather" + ) + enabled = models.BooleanField( + default=False, + help_text="Enable/Disable the bot" + ) + use_proxy = models.BooleanField( + default=False, + help_text="Enable proxy for Telegram API connections" + ) + proxy_url = models.URLField( + blank=True, + help_text="Proxy URL (e.g., http://proxy:8080 or socks5://proxy:1080)" + ) + api_base_url = models.URLField( + blank=True, + default="https://api.telegram.org", + help_text="Telegram API base URL (change for local bot API server)" + ) + connection_timeout = models.IntegerField( + default=30, + help_text="Connection timeout in seconds" + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Bot Settings" + verbose_name_plural = "Bot Settings" + + def save(self, *args, **kwargs): + # Ensure only one instance exists + self.pk = 1 + super().save(*args, **kwargs) + + def delete(self, *args, **kwargs): + # Prevent deletion + pass + + @classmethod + def get_settings(cls): + """Get or create singleton settings""" + obj, created = cls.objects.get_or_create(pk=1) + return obj + + def __str__(self): + return f"Bot Settings ({'Enabled' if self.enabled else 'Disabled'})" + + +class TelegramMessage(models.Model): + """Store all telegram messages""" + DIRECTION_CHOICES = [ + ('incoming', 'Incoming'), + ('outgoing', 'Outgoing'), + ] + + direction = models.CharField( + max_length=10, + choices=DIRECTION_CHOICES, + db_index=True + ) + + # Telegram user info + telegram_user_id = models.BigIntegerField(db_index=True) + telegram_username = models.CharField( + max_length=255, + blank=True, + null=True, + db_index=True + ) + telegram_first_name = models.CharField( + max_length=255, + blank=True, + null=True + ) + telegram_last_name = models.CharField( + max_length=255, + blank=True, + null=True + ) + user_language = models.CharField( + max_length=10, + default='en', + help_text="User's preferred language (en/ru)" + ) + + # Message info + chat_id = models.BigIntegerField(db_index=True) + message_id = models.BigIntegerField(null=True, blank=True) + message_text = models.TextField(blank=True) + + # Additional data + raw_data = models.JSONField( + default=dict, + blank=True, + help_text="Full message data from Telegram" + ) + + # Timestamps + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + + # Optional link to VPN user if identified + linked_user = models.ForeignKey( + User, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name='telegram_messages' + ) + + class Meta: + verbose_name = "Telegram Message" + verbose_name_plural = "Telegram Messages" + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['-created_at', 'direction']), + models.Index(fields=['telegram_user_id', '-created_at']), + ] + + def __str__(self): + username = self.telegram_username or f"ID:{self.telegram_user_id}" + direction_icon = "⬇️" if self.direction == 'incoming' else "⬆️" + text_preview = self.message_text[:50] + "..." if len(self.message_text) > 50 else self.message_text + return f"{direction_icon} {username}: {text_preview}" + + @property + def full_name(self): + """Get full name of telegram user""" + parts = [] + if self.telegram_first_name: + parts.append(self.telegram_first_name) + if self.telegram_last_name: + parts.append(self.telegram_last_name) + return " ".join(parts) if parts else f"User {self.telegram_user_id}" + + @property + def display_name(self): + """Get best available display name""" + if self.telegram_username: + return f"@{self.telegram_username}" + return self.full_name + + + + +class AccessRequest(models.Model): + """Access requests from Telegram users""" + + # Telegram user information + telegram_user_id = models.BigIntegerField( + db_index=True, + help_text="Telegram user ID who made the request" + ) + telegram_username = models.CharField( + max_length=255, + blank=True, + null=True, + help_text="Telegram username (without @)" + ) + telegram_first_name = models.CharField( + max_length=255, + blank=True, + null=True, + help_text="First name from Telegram" + ) + telegram_last_name = models.CharField( + max_length=255, + blank=True, + null=True, + help_text="Last name from Telegram" + ) + + # Request details + message_text = models.TextField( + help_text="The message sent by user when requesting access" + ) + chat_id = models.BigIntegerField( + help_text="Telegram chat ID for sending notifications" + ) + + # Username for VPN user creation + desired_username = models.CharField( + max_length=150, + blank=True, + help_text="Desired username for VPN user (defaults to Telegram username)" + ) + + # User language + user_language = models.CharField( + max_length=10, + default='en', + help_text="User's preferred language (en/ru)" + ) + + # Status and processing + approved = models.BooleanField( + default=False, + db_index=True, + help_text="Request approved by administrator" + ) + admin_comment = models.TextField( + blank=True, + help_text="Admin comment for approval" + ) + + # Related objects + created_user = models.ForeignKey( + User, + null=True, + blank=True, + on_delete=models.SET_NULL, + help_text="User created from this request (when approved)" + ) + processed_by = models.ForeignKey( + User, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name='processed_requests', + help_text="Admin who processed this request" + ) + first_message = models.ForeignKey( + TelegramMessage, + null=True, + blank=True, + on_delete=models.SET_NULL, + help_text="First message from this user" + ) + + # Timestamps + created_at = models.DateTimeField(auto_now_add=True) + processed_at = models.DateTimeField(null=True, blank=True) + + class Meta: + verbose_name = "Access Request" + verbose_name_plural = "Access Requests" + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['telegram_user_id']), + models.Index(fields=['approved', '-created_at']), + models.Index(fields=['-created_at']), + ] + constraints = [ + models.UniqueConstraint( + fields=['telegram_user_id'], + name='unique_telegram_user_request' + ) + ] + + def __str__(self): + username = self.telegram_username or f"ID:{self.telegram_user_id}" + status = "Approved" if self.approved else "Pending" + return f"Request from @{username} ({status})" + + @property + def display_name(self): + """Get best available display name""" + if self.telegram_username: + return f"@{self.telegram_username}" + + name_parts = [] + if self.telegram_first_name: + name_parts.append(self.telegram_first_name) + if self.telegram_last_name: + name_parts.append(self.telegram_last_name) + + if name_parts: + return " ".join(name_parts) + + return f"User {self.telegram_user_id}" + + @property + def full_name(self): + """Get full name of telegram user""" + parts = [] + if self.telegram_first_name: + parts.append(self.telegram_first_name) + if self.telegram_last_name: + parts.append(self.telegram_last_name) + return " ".join(parts) if parts else None diff --git a/telegram_bot/tests.py b/telegram_bot/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/telegram_bot/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/telegram_bot/views.py b/telegram_bot/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/telegram_bot/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/telegram_bot_locks/telegram_bot.lock b/telegram_bot_locks/telegram_bot.lock new file mode 100644 index 0000000..e69de29 diff --git a/vpn/admin.py b/vpn/admin.py index 82903af..970efbd 100644 --- a/vpn/admin.py +++ b/vpn/admin.py @@ -1,2040 +1,66 @@ +""" +Django admin configuration for VPN application + +This module has been refactored for better organization. The admin classes +are now split across multiple modules in the vpn.admin package: + +- vpn.admin.user: User management admin interface +- vpn.admin.server: Server management admin interface +- vpn.admin.access: Access control (ACL/ACLLink) admin interfaces +- vpn.admin.logs: Logging (TaskExecutionLog/AccessLog) admin interfaces +- vpn.admin.base: Common utilities and base classes +""" + +import logging +logger = logging.getLogger(__name__) + import json -import shortuuid -from polymorphic.admin import ( - PolymorphicParentModelAdmin, -) from django.contrib import admin from django.utils.safestring import mark_safe -from django.utils.html import format_html -from django.db.models import Count -from django.shortcuts import render, redirect -from django.contrib import messages -from django.urls import path, reverse -from django.http import HttpResponseRedirect - -from django.contrib.auth.admin import UserAdmin -from .models import User, AccessLog, TaskExecutionLog, UserStatistics -from django.utils.timezone import localtime -from vpn.models import User, ACL, ACLLink -from vpn.forms import UserForm -from mysite.settings import EXTERNAL_ADDRESS -from django.db.models import Max, Subquery, OuterRef, Q -def format_bytes(bytes_val): - """Format bytes to human readable format""" - for unit in ['B', 'KB', 'MB', 'GB', 'TB']: - if bytes_val < 1024.0: - return f"{bytes_val:.1f}{unit}" - bytes_val /= 1024.0 - return f"{bytes_val:.1f}PB" -from .server_plugins import ( - Server, - WireguardServer, - WireguardServerAdmin, - OutlineServer, - OutlineServerAdmin, - XrayServerV2, - XrayServerV2Admin) - -# Import new Xray admin configuration -from .admin_xray import add_subscription_management_to_user - -# This will be registered at the end of the file - - -@admin.register(TaskExecutionLog) -class TaskExecutionLogAdmin(admin.ModelAdmin): - list_display = ('task_name_display', 'action', 'status_display', 'server', 'user', 'execution_time_display', 'created_at') - list_filter = ('task_name', 'status', 'server', 'created_at') - search_fields = ('task_id', 'task_name', 'action', 'user__username', 'server__name', 'message') - readonly_fields = ('task_id', 'task_name', 'server', 'user', 'action', 'status', 'message_formatted', 'execution_time', 'created_at') - ordering = ('-created_at',) - list_per_page = 100 - date_hierarchy = 'created_at' - actions = ['trigger_full_sync', 'trigger_statistics_update'] - - fieldsets = ( - ('Task Information', { - 'fields': ('task_id', 'task_name', 'action', 'status') - }), - ('Related Objects', { - 'fields': ('server', 'user') - }), - ('Execution Details', { - 'fields': ('message_formatted', 'execution_time', 'created_at') - }), +# Import server plugins and their admin classes +try: + from .server_plugins import ( + XrayServerV2, + XrayServerV2Admin ) - - def trigger_full_sync(self, request, queryset): - """Trigger manual full synchronization of all servers""" - # This action doesn't require selected items - try: - from vpn.tasks import sync_all_users - - # Start the sync task - task = sync_all_users.delay() - - self.message_user( - request, - f'Full synchronization started successfully. Task ID: {task.id}. Check logs below for progress.', - level=messages.SUCCESS - ) - - except Exception as e: - self.message_user( - request, - f'Failed to start full synchronization: {e}', - level=messages.ERROR - ) - - trigger_full_sync.short_description = "πŸ”„ Trigger full sync of all servers" - - def trigger_statistics_update(self, request, queryset): - """Trigger manual update of user statistics cache""" - # This action doesn't require selected items - try: - from vpn.tasks import update_user_statistics - - # Start the statistics update task - task = update_user_statistics.delay() - - self.message_user( - request, - f'User statistics update started successfully. Task ID: {task.id}. Check logs below for progress.', - level=messages.SUCCESS - ) - - except Exception as e: - self.message_user( - request, - f'Failed to start statistics update: {e}', - level=messages.ERROR - ) - - trigger_statistics_update.short_description = "πŸ“Š Update user statistics cache" - - def get_actions(self, request): - """Remove default delete action for logs""" - actions = super().get_actions(request) - if 'delete_selected' in actions: - del actions['delete_selected'] - return actions - - @admin.display(description='Task', ordering='task_name') - def task_name_display(self, obj): - task_names = { - 'sync_all_servers': 'πŸ”„ Sync All', - 'sync_server_users': 'πŸ‘₯ Server Sync', - 'sync_server_info': 'βš™οΈ Server Info', - 'sync_user_on_server': 'πŸ‘€ User Sync', - 'cleanup_task_logs': '🧹 Cleanup', - 'update_user_statistics': 'πŸ“Š Statistics', - } - return task_names.get(obj.task_name, obj.task_name) - - @admin.display(description='Status', ordering='status') - def status_display(self, obj): - status_icons = { - 'STARTED': '🟑 Started', - 'SUCCESS': 'βœ… Success', - 'FAILURE': '❌ Failed', - 'RETRY': 'πŸ”„ Retry', - } - return status_icons.get(obj.status, obj.status) - - @admin.display(description='Time', ordering='execution_time') - def execution_time_display(self, obj): - if obj.execution_time: - if obj.execution_time < 1: - return f"{obj.execution_time*1000:.0f}ms" - else: - return f"{obj.execution_time:.2f}s" - return '-' - - @admin.display(description='Message') - def message_formatted(self, obj): - if obj.message: - return mark_safe(f"
{obj.message}
") - return '-' - - def has_add_permission(self, request): - return False - - def has_change_permission(self, request, obj=None): - return False - - def changelist_view(self, request, extra_context=None): - """Override to handle actions that don't require item selection""" - # Handle actions that don't require selection - if 'action' in request.POST: - action = request.POST['action'] - if action == 'trigger_full_sync': - # Call the action directly without queryset requirement - self.trigger_full_sync(request, None) - # Return redirect to prevent AttributeError - return redirect(request.get_full_path()) - elif action == 'trigger_statistics_update': - # Call the statistics update action - self.trigger_statistics_update(request, None) - # Return redirect to prevent AttributeError - return redirect(request.get_full_path()) - - return super().changelist_view(request, extra_context) +except Exception as e: + logger.error(f"❌ Failed to import server plugins: {e}") +# Import admin interfaces from refactored modules +# This ensures all admin classes are registered +try: + from .admin import * +except Exception as e: + logger.error(f"❌ Failed to import refactored admin modules: {e}") + import traceback + logger.error(f"Traceback: {traceback.format_exc()}") +# Import Xray admin configuration and all Xray admin classes +try: + from .admin_xray import * +except Exception as e: + logger.error(f"❌ Failed to import Xray admin classes: {e}") + import traceback + logger.error(f"Traceback: {traceback.format_exc()}") + +# Set custom admin site configuration admin.site.site_title = "VPN Manager" admin.site.site_header = "VPN Manager" admin.site.index_title = "OutFleet" -def format_object(data): - try: - if isinstance(data, dict): - formatted_data = json.dumps(data, indent=2) - return mark_safe(f"
{formatted_data}
") - elif isinstance(data, str): - return mark_safe(f"
{data}
") - else: - return mark_safe(f"
{str(data)}
") - except Exception as e: - return mark_safe(f"Error: {e}") - -class UserNameFilter(admin.SimpleListFilter): - title = 'User' - parameter_name = 'user' - - def lookups(self, request, model_admin): - users = set(User.objects.values_list('username', flat=True)) - return [(user, user) for user in users] - - def queryset(self, request, queryset): - if self.value(): - return queryset.filter(user__username=self.value()) - return queryset - -class ServerNameFilter(admin.SimpleListFilter): - title = 'Server Name' - parameter_name = 'acl__server__name' - - def lookups(self, request, model_admin): - servers = set(ACL.objects.values_list('server__name', flat=True)) - return [(server, server) for server in servers] - - def queryset(self, request, queryset): - if self.value(): - return queryset.filter(acl__server__name=self.value()) - return queryset - - -class LastAccessFilter(admin.SimpleListFilter): - title = 'Last Access' - parameter_name = 'last_access_status' - - def lookups(self, request, model_admin): - return [ - ('never', 'Never accessed'), - ('week', 'Last week'), - ('month', 'Last month'), - ('old', 'Older than 3 months'), - ] - - def queryset(self, request, queryset): - from django.utils import timezone - from datetime import timedelta - - if self.value() == 'never': - # Links that have never been accessed - return queryset.filter(last_access_time__isnull=True) - elif self.value() == 'week': - # Links accessed in the last week - week_ago = timezone.now() - timedelta(days=7) - return queryset.filter(last_access_time__gte=week_ago) - elif self.value() == 'month': - # Links accessed in the last month - month_ago = timezone.now() - timedelta(days=30) - return queryset.filter(last_access_time__gte=month_ago) - elif self.value() == 'old': - # Links not accessed for more than 3 months - three_months_ago = timezone.now() - timedelta(days=90) - return queryset.filter(last_access_time__lt=three_months_ago) - return queryset - -@admin.register(Server) -class ServerAdmin(PolymorphicParentModelAdmin): - base_model = Server - child_models = (OutlineServer, WireguardServer, XrayServerV2) - list_display = ('name_with_icon', 'server_type', 'comment_short', 'user_stats', 'server_status_compact', 'registration_date') - search_fields = ('name', 'comment') - list_filter = ('server_type', ) - actions = ['move_clients_action', 'purge_all_keys_action', 'sync_all_selected_servers', 'sync_xray_inbounds', 'check_status'] - - class Media: - css = { - 'all': ('admin/css/vpn_admin.css',) - } - js = ('admin/js/server_status_check.js',) - - def get_urls(self): - urls = super().get_urls() - custom_urls = [ - path('move-clients/', self.admin_site.admin_view(self.move_clients_view), name='server_move_clients'), - path('/check-status/', self.admin_site.admin_view(self.check_server_status_view), name='server_check_status'), - path('/sync/', self.admin_site.admin_view(self.sync_server_view), name='server_sync'), - ] - return custom_urls + urls - - def move_clients_action(self, request, queryset): - """ΠšΠ°ΡΡ‚ΠΎΠΌΠ½ΠΎΠ΅ дСйствиС для ΠΏΠ΅Ρ€Π΅Ρ…ΠΎΠ΄Π° ΠΊ страницС пСрСноса ΠΊΠ»ΠΈΠ΅Π½Ρ‚ΠΎΠ²""" - if queryset.count() == 0: - self.message_user(request, "Π’Ρ‹Π±Π΅Ρ€ΠΈΡ‚Π΅ хотя Π±Ρ‹ ΠΎΠ΄ΠΈΠ½ сСрвСр.", level=messages.ERROR) - return - - # ΠŸΠ΅Ρ€Π΅Π½Π°ΠΏΡ€Π°Π²Π»ΡΠ΅ΠΌ Π½Π° страницу пСрСноса ΠΊΠ»ΠΈΠ΅Π½Ρ‚ΠΎΠ² - selected_ids = ','.join(str(server.id) for server in queryset) - return HttpResponseRedirect(f"{reverse('admin:server_move_clients')}?servers={selected_ids}") - - move_clients_action.short_description = "Move client links between servers" - - def move_clients_view(self, request): - """View for moving clients between servers""" - if request.method == 'GET': - # Get selected servers from URL parameters - server_ids = request.GET.get('servers', '').split(',') - if not server_ids or server_ids == ['']: - messages.error(request, "No servers selected.") - return redirect('admin:vpn_server_changelist') - - try: - # Only work with database objects, don't check server connectivity - servers = Server.objects.filter(id__in=server_ids) - all_servers = Server.objects.all() - - # Get ACL links for selected servers with related data - # This is purely database operation, no server connectivity required - links_by_server = {} - for server in servers: - try: - # Get all ACL links for this server with user and ACL data - links = ACLLink.objects.filter( - acl__server=server - ).select_related('acl__user', 'acl__server').order_by('acl__user__username', 'comment') - links_by_server[server] = links - except Exception as e: - # Log the error but continue with other servers - messages.warning(request, f"Warning: Could not load links for server {server.name}: {e}") - links_by_server[server] = [] - - context = { - 'title': 'Move Client Links Between Servers', - 'servers': servers, - 'all_servers': all_servers, - 'links_by_server': links_by_server, - } - - return render(request, 'admin/move_clients.html', context) - - except Exception as e: - messages.error(request, f"Database error while loading data: {e}") - return redirect('admin:vpn_server_changelist') - - elif request.method == 'POST': - # Process the transfer of ACL links - purely database operations - try: - source_server_id = request.POST.get('source_server') - target_server_id = request.POST.get('target_server') - selected_link_ids = request.POST.getlist('selected_links') - comment_regex = request.POST.get('comment_regex', '').strip() - - if not source_server_id or not target_server_id: - messages.error(request, "Please select both source and target servers.") - return redirect(request.get_full_path()) - - if source_server_id == target_server_id: - messages.error(request, "Source and target servers cannot be the same.") - return redirect(request.get_full_path()) - - if not selected_link_ids: - messages.error(request, "Please select at least one link to move.") - return redirect(request.get_full_path()) - - # Parse and validate regex pattern if provided - regex_pattern = None - regex_replacement = None - regex_parts = None - if comment_regex: - try: - import re - regex_parts = comment_regex.split(' -> ') - if len(regex_parts) != 2: - messages.error(request, "Invalid regex format. Use: pattern -> replacement") - return redirect(request.get_full_path()) - - pattern_str = regex_parts[0] - replacement_str = regex_parts[1] - - # Convert JavaScript-style $1, $2, $3 to Python-style \1, \2, \3 - python_replacement = replacement_str - import re as regex_module - # Replace $1, $2, etc. with \1, \2, etc. for Python regex - python_replacement = regex_module.sub(r'\$(\d+)', r'\\\1', replacement_str) - - # Test compile the regex pattern - regex_pattern = re.compile(pattern_str) - regex_replacement = python_replacement - - # Test the replacement on a sample string to validate syntax - test_result = regex_pattern.sub(regex_replacement, "test sample") - - except re.error as e: - messages.error(request, f"Invalid regular expression pattern '{regex_parts[0] if regex_parts else comment_regex}': {e}") - return redirect(request.get_full_path()) - except Exception as e: - messages.error(request, f"Error in regex replacement '{replacement_str if 'replacement_str' in locals() else 'unknown'}': {e}") - return redirect(request.get_full_path()) - - # Get server objects from database only - try: - source_server = Server.objects.get(id=source_server_id) - target_server = Server.objects.get(id=target_server_id) - except Server.DoesNotExist: - messages.error(request, "One of the selected servers was not found in database.") - return redirect('admin:vpn_server_changelist') - - moved_count = 0 - errors = [] - users_processed = set() - comments_transformed = 0 - - # Process each selected link - database operations only - for link_id in selected_link_ids: - try: - # Get the ACL link with related ACL and user data - acl_link = ACLLink.objects.select_related('acl__user', 'acl__server').get( - id=link_id, - acl__server=source_server - ) - user = acl_link.acl.user - - # Apply regex transformation to comment if provided - original_comment = acl_link.comment - if regex_pattern and regex_replacement is not None: - try: - # Use Python's re.sub for replacement, which properly handles $1, $2 groups - new_comment = regex_pattern.sub(regex_replacement, original_comment) - if new_comment != original_comment: - acl_link.comment = new_comment - comments_transformed += 1 - # Debug logging - shows both original and converted patterns - print(f"DEBUG: Transformed '{original_comment}' -> '{new_comment}'") - print(f" Original pattern: '{regex_parts[0]}' -> '{regex_parts[1]}'") - print(f" Python pattern: '{regex_parts[0]}' -> '{regex_replacement}'") - except Exception as e: - errors.append(f"Error applying regex to link {link_id} ('{original_comment}'): {e}") - # Continue with original comment - - # Check if user already has ACL on target server - target_acl = ACL.objects.filter(user=user, server=target_server).first() - - if target_acl: - created = False - else: - # Create new ACL without auto-creating default link - target_acl = ACL(user=user, server=target_server) - target_acl.save(auto_create_link=False) - created = True - - # Move the link to target ACL - pure database operation - acl_link.acl = target_acl - acl_link.save() - - moved_count += 1 - users_processed.add(user.username) - - if created: - messages.info(request, f"Created new ACL for user {user.username} on server {target_server.name}") - - except ACLLink.DoesNotExist: - errors.append(f"Link with ID {link_id} not found on source server") - except Exception as e: - errors.append(f"Database error moving link {link_id}: {e}") - - # Clean up empty ACLs on source server - database operation only - try: - empty_acls = ACL.objects.filter( - server=source_server, - links__isnull=True - ) - deleted_acls_count = empty_acls.count() - empty_acls.delete() - except Exception as e: - messages.warning(request, f"Warning: Could not clean up empty ACLs: {e}") - deleted_acls_count = 0 - - if moved_count > 0: - success_msg = ( - f"Successfully moved {moved_count} link(s) for {len(users_processed)} user(s) " - f"from '{source_server.name}' to '{target_server.name}'. " - f"Cleaned up {deleted_acls_count} empty ACL(s)." - ) - if comments_transformed > 0: - success_msg += f" Transformed {comments_transformed} comment(s) using regex." - - messages.success(request, success_msg) - - if errors: - for error in errors: - messages.error(request, error) - - return redirect('admin:vpn_server_changelist') - - except Exception as e: - messages.error(request, f"Database error during link transfer: {e}") - return redirect('admin:vpn_server_changelist') - - def check_server_status_view(self, request, server_id): - """AJAX view to check server status""" - from django.http import JsonResponse - import logging - - logger = logging.getLogger(__name__) - - if request.method == 'POST': - try: - logger.info(f"Checking status for server ID: {server_id}") - server = Server.objects.get(pk=server_id) - real_server = server.get_real_instance() - logger.info(f"Server found: {server.name}, type: {type(real_server).__name__}") - - # Check server status based on type - from vpn.server_plugins.outline import OutlineServer - # Old xray_core module removed - skip this server type - - if isinstance(real_server, OutlineServer): - try: - logger.info(f"Checking Outline server: {server.name}") - # Try to get server info to check if it's online - info = real_server.client.get_server_information() - if info: - logger.info(f"Server {server.name} is online with {info.get('accessKeyCount', info.get('access_key_count', 'unknown'))} keys") - return JsonResponse({ - 'success': True, - 'status': 'online', - 'message': f'Server is online. Keys: {info.get("accessKeyCount", info.get("access_key_count", "unknown"))}' - }) - else: - logger.warning(f"Server {server.name} returned no info") - return JsonResponse({ - 'success': True, - 'status': 'offline', - 'message': 'Server not responding' - }) - except Exception as e: - logger.error(f"Error checking Outline server {server.name}: {e}") - return JsonResponse({ - 'success': True, - 'status': 'error', - 'message': f'Connection error: {str(e)[:100]}' - }) - - elif isinstance(real_server, XrayServerV2): - try: - logger.info(f"Checking Xray v2 server: {server.name}") - # Get server status from new Xray implementation - status = real_server.get_server_status() - if status and isinstance(status, dict): - if status.get('accessible', False): - message = f'βœ… Server is {status.get("status", "accessible")}. ' - message += f'Host: {status.get("client_hostname", "N/A")}, ' - message += f'API: {status.get("api_address", "N/A")}' - - if status.get('api_connected'): - message += ' (Connected)' - # Add stats if available - api_stats = status.get('api_stats', {}) - if api_stats and isinstance(api_stats, dict): - if 'connection' in api_stats: - message += f', Stats: {api_stats.get("connection", "ok")}' - if api_stats.get('library') == 'not_available': - message += ' [Basic check only]' - elif status.get('api_error'): - message += f' ({status.get("api_error")})' - - message += f', Inbounds: {status.get("total_inbounds", 0)}' - - logger.info(f"Xray v2 server {server.name} status: {message}") - return JsonResponse({ - 'success': True, - 'status': 'online', - 'message': message - }) - else: - error_msg = status.get('error') or status.get('api_error', 'Unknown error') - logger.warning(f"Xray v2 server {server.name} not accessible: {error_msg}") - return JsonResponse({ - 'success': True, - 'status': 'offline', - 'message': f'❌ Server not accessible: {error_msg}' - }) - else: - logger.warning(f"Xray v2 server {server.name} returned invalid status") - return JsonResponse({ - 'success': True, - 'status': 'offline', - 'message': 'Invalid server response' - }) - except Exception as e: - logger.error(f"Error checking Xray v2 server {server.name}: {e}") - return JsonResponse({ - 'success': True, - 'status': 'error', - 'message': f'Connection error: {str(e)[:100]}' - }) - - else: - # For other server types, just return basic info - logger.info(f"Server {server.name}, type: {server.server_type}") - return JsonResponse({ - 'success': True, - 'status': 'unknown', - 'message': f'Status check not implemented for {server.server_type} servers' - }) - - except Server.DoesNotExist: - logger.error(f"Server with ID {server_id} not found") - return JsonResponse({ - 'success': False, - 'error': 'Server not found' - }, status=404) - except Exception as e: - logger.error(f"Unexpected error checking server {server_id}: {e}") - return JsonResponse({ - 'success': False, - 'error': f'Unexpected error: {str(e)}' - }, status=500) - - logger.warning(f"Invalid request method {request.method} for server status check") - return JsonResponse({ - 'success': False, - 'error': 'Invalid request method' - }, status=405) - - def purge_all_keys_action(self, request, queryset): - """Purge all keys from selected servers without changing database""" - if queryset.count() == 0: - self.message_user(request, "Please select at least one server.", level=messages.ERROR) - return - - success_count = 0 - error_count = 0 - total_keys_removed = 0 - - for server in queryset: - try: - # Get the real polymorphic instance - real_server = server.get_real_instance() - server_type = type(real_server).__name__ - - # Check if this is an Outline server - from vpn.server_plugins.outline import OutlineServer - - if isinstance(real_server, OutlineServer) and hasattr(real_server, 'client'): - # For Outline servers, get all keys and delete them - try: - keys = real_server.client.get_keys() - keys_count = len(keys) - - for key in keys: - try: - real_server.client.delete_key(key.key_id) - except Exception as e: - self.message_user( - request, - f"Failed to delete key {key.key_id} from {server.name}: {e}", - level=messages.WARNING - ) - - total_keys_removed += keys_count - success_count += 1 - self.message_user( - request, - f"Successfully purged {keys_count} keys from server '{server.name}'.", - level=messages.SUCCESS - ) - - except Exception as e: - error_count += 1 - self.message_user( - request, - f"Failed to connect to server '{server.name}': {e}", - level=messages.ERROR - ) - else: - self.message_user( - request, - f"Key purging only supported for Outline servers. Skipping '{server.name}' (type: {server_type}).", - level=messages.INFO - ) - - except Exception as e: - error_count += 1 - self.message_user( - request, - f"Unexpected error with server '{server.name}': {e}", - level=messages.ERROR - ) - - # Summary message - if success_count > 0: - self.message_user( - request, - f"Purge completed! {success_count} server(s) processed, {total_keys_removed} total keys removed. " - f"Database unchanged - run sync to restore proper keys.", - level=messages.SUCCESS - ) - - if error_count > 0: - self.message_user( - request, - f"{error_count} server(s) had errors during purge.", - level=messages.WARNING - ) - - purge_all_keys_action.short_description = "πŸ—‘οΈ Purge all keys from server (database unchanged)" - - def sync_all_selected_servers(self, request, queryset): - """Trigger sync for all users on selected servers""" - if queryset.count() == 0: - self.message_user(request, "Please select at least one server.", level=messages.ERROR) - return - - try: - from vpn.tasks import sync_server_users - - tasks_started = 0 - errors = [] - - for server in queryset: - try: - task = sync_server_users.delay(server.id) - tasks_started += 1 - self.message_user( - request, - f"πŸ”„ Sync task started for '{server.name}' (Task ID: {task.id})", - level=messages.SUCCESS - ) - except Exception as e: - errors.append(f"'{server.name}': {e}") - - if tasks_started > 0: - self.message_user( - request, - f"βœ… Started sync tasks for {tasks_started} server(s). Check Task Execution Logs for progress.", - level=messages.SUCCESS - ) - - if errors: - for error in errors: - self.message_user( - request, - f"❌ Failed to sync {error}", - level=messages.ERROR - ) - - except Exception as e: - self.message_user( - request, - f"❌ Failed to start sync tasks: {e}", - level=messages.ERROR - ) - - sync_all_selected_servers.short_description = "πŸ”„ Sync all users on selected servers" - - def check_status(self, request, queryset): - """Check status for selected servers""" - for server in queryset: - try: - status = server.get_server_status() - msg = f"{server.name}: {status.get('accessible', 'Unknown')} - {status.get('status', 'N/A')}" - self.message_user(request, msg, level=messages.INFO) - except Exception as e: - self.message_user(request, f"Error checking {server.name}: {e}", level=messages.ERROR) - check_status.short_description = "πŸ“Š Check server status" - - def sync_xray_inbounds(self, request, queryset): - """Sync inbounds for selected servers (Xray v2 only)""" - from .server_plugins.xray_v2 import XrayServerV2 - synced_count = 0 - - for server in queryset: - try: - real_server = server.get_real_instance() - if isinstance(real_server, XrayServerV2): - real_server.sync_inbounds() - synced_count += 1 - self.message_user(request, f"Scheduled inbound sync for {server.name}", level=messages.SUCCESS) - else: - self.message_user(request, f"{server.name} is not an Xray v2 server", level=messages.WARNING) - except Exception as e: - self.message_user(request, f"Error syncing inbounds for {server.name}: {e}", level=messages.ERROR) - - if synced_count > 0: - self.message_user(request, f"Scheduled inbound sync for {synced_count} server(s)", level=messages.SUCCESS) - sync_xray_inbounds.short_description = "πŸ”§ Sync Xray inbounds" - - @admin.display(description='Server', ordering='name') - def name_with_icon(self, obj): - """Display server name with type icon""" - icons = { - 'outline': 'πŸ”΅', - 'wireguard': '🟒', - 'xray_core': '🟣', - 'xray_v2': '🟑', - } - icon = icons.get(obj.server_type, '') - name_part = f"{icon} {obj.name}" if icon else obj.name - return name_part - - @admin.display(description='Comment') - def comment_short(self, obj): - """Display shortened comment""" - if obj.comment: - short_comment = obj.comment[:40] + '...' if len(obj.comment) > 40 else obj.comment - return mark_safe(f'{short_comment}') - return '-' - - @admin.display(description='Users & Links') - def user_stats(self, obj): - """Display user count and active links statistics (optimized)""" - try: - from django.utils import timezone - from datetime import timedelta - - user_count = obj.user_count if hasattr(obj, 'user_count') else 0 - - # Different logic for Xray vs legacy servers - if obj.server_type == 'xray_v2': - # For Xray servers, count inbounds and active subscriptions - from vpn.models_xray import ServerInbound - total_inbounds = ServerInbound.objects.filter(server=obj, active=True).count() - - # Count recent subscription accesses via AccessLog - thirty_days_ago = timezone.now() - timedelta(days=30) - from vpn.models import AccessLog - active_accesses = AccessLog.objects.filter( - server='Xray-Subscription', - action='Success', - timestamp__gte=thirty_days_ago - ).values('user').distinct().count() - - total_links = total_inbounds - active_links = min(active_accesses, user_count) # Can't be more than total users - else: - # Legacy servers: use ACL links as before - if hasattr(obj, 'acl_set'): - all_links = [] - for acl in obj.acl_set.all(): - if hasattr(acl, 'links') and hasattr(acl.links, 'all'): - all_links.extend(acl.links.all()) - - total_links = len(all_links) - - # Count active links from prefetched data - thirty_days_ago = timezone.now() - timedelta(days=30) - active_links = sum(1 for link in all_links - if link.last_access_time and link.last_access_time >= thirty_days_ago) - else: - # Fallback to direct queries (less efficient) - total_links = ACLLink.objects.filter(acl__server=obj).count() - thirty_days_ago = timezone.now() - timedelta(days=30) - active_links = ACLLink.objects.filter( - acl__server=obj, - last_access_time__isnull=False, - last_access_time__gte=thirty_days_ago - ).count() - - # Color coding based on activity - if user_count == 0: - color = '#9ca3af' # gray - no users - elif total_links == 0: - color = '#dc2626' # red - no links/inbounds - elif obj.server_type == 'xray_v2': - # For Xray: base on user activity rather than link activity - if active_links > user_count * 0.5: # More than half users active - color = '#16a34a' # green - elif active_links > user_count * 0.2: # More than 20% users active - color = '#eab308' # yellow - else: - color = '#f97316' # orange - low activity - else: - # Legacy servers: base on link activity - if total_links > 0 and active_links > total_links * 0.7: # High activity - color = '#16a34a' # green - elif total_links > 0 and active_links > total_links * 0.3: # Medium activity - color = '#eab308' # yellow - else: - color = '#f97316' # orange - low activity - - # Different display for Xray vs legacy - if obj.server_type == 'xray_v2': - # Try to get traffic stats if stats enabled - traffic_info = "" - # Get the real XrayServerV2 instance to access its fields - xray_server = obj.get_real_instance() - if hasattr(xray_server, 'stats_enabled') and xray_server.stats_enabled and xray_server.api_enabled: - try: - from vpn.xray_api_v2.client import XrayClient - from vpn.xray_api_v2.stats import StatsManager - - client = XrayClient(server=xray_server.api_address) - stats_manager = StatsManager(client) - traffic_summary = stats_manager.get_traffic_summary() - - # Calculate total traffic - total_uplink = 0 - total_downlink = 0 - - # Sum up user traffic - for user_email, user_traffic in traffic_summary.get('users', {}).items(): - total_uplink += user_traffic.get('uplink', 0) - total_downlink += user_traffic.get('downlink', 0) - - # Format traffic - - if total_uplink > 0 or total_downlink > 0: - traffic_info = f'
↑{format_bytes(total_uplink)} ↓{format_bytes(total_downlink)}
' - except Exception as e: - import logging - logger = logging.getLogger(__name__) - logger.debug(f"Could not get stats for Xray server {xray_server.name}: {e}") - - return mark_safe( - f'
' + - f'
πŸ‘₯ {user_count} users
' + - f'
πŸ“‘ {total_links} inbounds
' + - traffic_info + - f'
' - ) - else: - return mark_safe( - f'
' + - f'
πŸ‘₯ {user_count} users
' + - f'
πŸ”— {active_links}/{total_links} active
' + - f'
' - ) - except Exception as e: - import traceback - import logging - logger = logging.getLogger(__name__) - logger.error(f"Error in user_stats for server {obj.name}: {e}", exc_info=True) - return mark_safe(f'Stats error: {e}') - - @admin.display(description='Activity') - def activity_summary(self, obj): - """Display recent activity summary (optimized)""" - try: - # Simplified version - avoid heavy DB queries on list page - # This could be computed once per page load if needed - return mark_safe( - f'
' + - f'
πŸ“Š Activity data
' + - f'
Click to view details
' + - f'
' - ) - except Exception as e: - return mark_safe(f'Activity unavailable') - - @admin.display(description='Status') - def server_status_compact(self, obj): - """Display server status in compact format (optimized)""" - try: - # Avoid expensive server connectivity checks on list page - # Show basic info and let users click to check status - server_type_icons = { - 'outline': 'πŸ”΅', - 'wireguard': '🟒', - 'xray_core': '🟣', - } - icon = server_type_icons.get(obj.server_type, 'βšͺ') - - return mark_safe( - f'
' + - f'{icon} {obj.server_type.title()}
' + - f'' + - f'
' - ) - except Exception as e: - return mark_safe( - f'
' + - f'⚠️ Error
' + - f'' + - f'{str(e)[:25]}...' + - f'
' - ) - - def get_queryset(self, request): - from django.db.models import Case, When, Value, IntegerField, F, Q, Subquery, OuterRef - from vpn.models_xray import UserSubscription, ServerInbound - - qs = super().get_queryset(request) - - # Count ACL users for all servers - qs = qs.annotate( - acl_user_count=Count('acl__user', distinct=True) - ) - - # For Xray servers, calculate user count separately - # Create subquery to count Xray users - xray_user_count_subquery = ServerInbound.objects.filter( - server_id=OuterRef('pk'), - active=True, - inbound__subscriptiongroup__usersubscription__active=True, - inbound__subscriptiongroup__is_active=True - ).values('server_id').annotate( - count=Count('inbound__subscriptiongroup__usersubscription__user', distinct=True) - ).values('count') - - qs = qs.annotate( - xray_user_count=Subquery(xray_user_count_subquery, output_field=IntegerField()), - user_count=Case( - When(server_type='xray_v2', then=F('xray_user_count')), - default=F('acl_user_count'), - output_field=IntegerField() - ) - ) - - # Handle None values from subquery - qs = qs.annotate( - user_count=Case( - When(server_type='xray_v2', user_count__isnull=True, then=Value(0)), - When(server_type='xray_v2', then=F('xray_user_count')), - default=F('acl_user_count'), - output_field=IntegerField() - ) - ) - - qs = qs.prefetch_related( - 'acl_set__links', - 'acl_set__user' - ) - return qs - - def sync_server_view(self, request, object_id): - """Dispatch sync to appropriate server type.""" - from django.shortcuts import redirect, get_object_or_404 - from django.contrib import messages - # XrayCoreServer removed - using XrayServerV2 now - - try: - server = get_object_or_404(Server, pk=object_id) - real_server = server.get_real_instance() - - # Handle XrayServerV2 - if isinstance(real_server, XrayServerV2): - return redirect(f'/admin/vpn/xrayserverv2/{real_server.pk}/sync/') - - # Fallback for other server types - else: - messages.info(request, f"Sync not implemented for server type: {real_server.server_type}") - return redirect('admin:vpn_server_changelist') - - except Exception as e: - messages.error(request, f"Error during sync: {e}") - return redirect('admin:vpn_server_changelist') - -#admin.site.register(User, UserAdmin) -# Inline for legacy VPN access (Outline/Wireguard) -class UserACLInline(admin.TabularInline): - model = ACL - extra = 0 - fields = ('server', 'created_at', 'link_count') - readonly_fields = ('created_at', 'link_count') - verbose_name = "Legacy VPN Server Access" - verbose_name_plural = "Legacy VPN Server Access (Outline/Wireguard)" - - def formfield_for_foreignkey(self, db_field, request, **kwargs): - if db_field.name == "server": - # Only show old-style servers (Outline/Wireguard) - kwargs["queryset"] = Server.objects.exclude(server_type='xray_v2') - return super().formfield_for_foreignkey(db_field, request, **kwargs) - - @admin.display(description='Links') - def link_count(self, obj): - count = obj.links.count() - return format_html( - '{} link(s)', - count - ) - - - -@admin.register(User) -class UserAdmin(admin.ModelAdmin): - form = UserForm - list_display = ('username', 'comment', 'registration_date', 'hash_link', 'server_count') - search_fields = ('username', 'hash') - readonly_fields = ('hash_link', 'vpn_access_summary', 'user_statistics_summary') - inlines = [] # All VPN access info is now in vpn_access_summary - - class Media: - css = { - 'all': ('admin/css/vpn_admin.css',) - } - - fieldsets = ( - ('User Information', { - 'fields': ('username', 'first_name', 'last_name', 'email', 'comment') - }), - ('Access Information', { - 'fields': ('hash_link', 'is_active', 'vpn_access_summary') - }), - ('Statistics & Server Management', { - 'fields': ('user_statistics_summary',), - 'classes': ('wide',) - }), - ) - - @admin.display(description='VPN Access Summary') - def vpn_access_summary(self, obj): - """Display summary of user's VPN access""" - if not obj.pk: - return "Save user first to see VPN access" - - # Get legacy VPN access - acl_count = ACL.objects.filter(user=obj).count() - legacy_links = ACLLink.objects.filter(acl__user=obj).count() - - # Get Xray access - from vpn.models_xray import UserSubscription - xray_subs = UserSubscription.objects.filter(user=obj, active=True).select_related('subscription_group') - xray_groups = [sub.subscription_group.name for sub in xray_subs] - - html = '
' - - # Legacy VPN section - html += '
' - html += '

πŸ“‘ Legacy VPN (Outline/Wireguard)

' - if acl_count > 0: - html += f'

βœ… Access to {acl_count} server(s)

' - html += f'

πŸ”— Total links: {legacy_links}

' - else: - html += '

No legacy VPN access

' - html += '
' - - # Xray section - html += '
' - html += '

πŸš€ Xray VPN

' - if xray_groups: - html += f'

βœ… Active subscriptions: {len(xray_groups)}

' - html += '
    ' - for group in xray_groups: - html += f'
  • {group}
  • ' - html += '
' - - # Try to get traffic statistics for this user - try: - from vpn.server_plugins.xray_v2 import XrayServerV2 - traffic_total_up = 0 - traffic_total_down = 0 - servers_checked = set() - - # Get all Xray servers - xray_servers = XrayServerV2.objects.filter(api_enabled=True, stats_enabled=True) - - for server in xray_servers: - if server.name not in servers_checked: - try: - from vpn.xray_api_v2.client import XrayClient - from vpn.xray_api_v2.stats import StatsManager - - client = XrayClient(server=server.api_address) - stats_manager = StatsManager(client) - - # Get user stats (use email format: username@servername) - user_email = f"{obj.username}@{server.name}" - user_stats = stats_manager.get_user_stats(user_email) - - if user_stats: - traffic_total_up += user_stats.get('uplink', 0) - traffic_total_down += user_stats.get('downlink', 0) - - servers_checked.add(server.name) - except Exception as e: - import logging - logger = logging.getLogger(__name__) - logger.debug(f"Could not get user stats from server {server.name}: {e}") - - # Format traffic if we got any - if traffic_total_up > 0 or traffic_total_down > 0: - - html += f'

πŸ“Š Traffic Statistics:

' - html += f'

↑ Upload: {format_bytes(traffic_total_up)}

' - html += f'

↓ Download: {format_bytes(traffic_total_down)}

' - except Exception as e: - import logging - logger = logging.getLogger(__name__) - logger.debug(f"Could not get traffic stats for user {obj.username}: {e}") - else: - html += '

No Xray subscriptions

' - html += '
' - - html += '
' - - return format_html(html) - - @admin.display(description='User Portal', ordering='hash') - def hash_link(self, obj): - portal_url = f"{EXTERNAL_ADDRESS}/u/{obj.hash}" - json_url = f"{EXTERNAL_ADDRESS}/stat/{obj.hash}" - return format_html( - '
' + - '🌐 Portal' + - 'πŸ“„ JSON' + - '
', - portal_url, json_url - ) - - @admin.display(description='User Statistics Summary') - def user_statistics_summary(self, obj): - """Display user statistics with integrated server management""" - try: - from .models import UserStatistics - from django.db import models - - # Get statistics for this user - user_stats = UserStatistics.objects.filter(user=obj).aggregate( - total_connections=models.Sum('total_connections'), - recent_connections=models.Sum('recent_connections'), - total_links=models.Count('id'), - max_daily_peak=models.Max('max_daily') - ) - - # Get server breakdown - server_breakdown = UserStatistics.objects.filter(user=obj).values('server_name').annotate( - connections=models.Sum('total_connections'), - links=models.Count('id') - ).order_by('-connections') - - # Get all ACLs and links for this user - user_acls = ACL.objects.filter(user=obj).select_related('server').prefetch_related('links') - - # Get available servers not yet assigned - all_servers = Server.objects.all() - assigned_server_ids = [acl.server.id for acl in user_acls] - unassigned_servers = all_servers.exclude(id__in=assigned_server_ids) - - html = '
' - - # Overall Statistics - html += '
' - html += f'
' - html += f'
Total Uses: {user_stats["total_connections"] or 0}
' - html += f'
Recent (30d): {user_stats["recent_connections"] or 0}
' - html += f'
Total Links: {user_stats["total_links"] or 0}
' - if user_stats["max_daily_peak"]: - html += f'
Daily Peak: {user_stats["max_daily_peak"]}
' - html += f'
' - html += '
' - - # Server Management - if user_acls: - html += '

πŸ”— Server Access & Links

' - - for acl in user_acls: - server = acl.server - links = list(acl.links.all()) - - # Server header (no slow server status checks) - # Determine server type icon and label - if server.server_type == 'Outline': - type_icon = 'πŸ”΅' - type_label = 'Outline' - elif server.server_type == 'Wireguard': - type_icon = '🟒' - type_label = 'Wireguard' - elif server.server_type in ['xray_core', 'xray_v2']: - type_icon = '🟣' - type_label = 'Xray' - else: - type_icon = '❓' - type_label = server.server_type - - html += f'
' - html += f'
{type_icon} {server.name} ({type_label})
' - - # Server stats - server_stat = next((s for s in server_breakdown if s['server_name'] == server.name), None) - if server_stat: - html += f'' - html += f'πŸ“Š {server_stat["connections"]} uses ({server_stat["links"]} links)' - html += f'' - html += f'
' - - html += '
' - - # Links display - if links: - for link in links: - # Get link stats - link_stats = UserStatistics.objects.filter( - user=obj, server_name=server.name, acl_link_id=link.link - ).first() - - html += '' - - # Add link button - html += f'
' - html += f'' - html += f'
' - - html += '
' # End server-section - - # Add server access section - if unassigned_servers: - html += '
' - html += '
βž• Available Servers
' - html += '
' - for server in unassigned_servers: - # Determine server type icon and label - if server.server_type == 'Outline': - type_icon = 'πŸ”΅' - type_label = 'Outline' - elif server.server_type == 'Wireguard': - type_icon = '🟒' - type_label = 'Wireguard' - elif server.server_type in ['xray_core', 'xray_v2']: - type_icon = '🟣' - type_label = 'Xray' - else: - type_icon = '❓' - type_label = server.server_type - - html += f'' - html += '
' - - html += '
' # End user-management-section - return mark_safe(html) - - except Exception as e: - return mark_safe(f'Error loading management interface: {e}') - - @admin.display(description='Recent Activity') - def recent_activity_display(self, obj): - """Display recent activity in compact admin-friendly format""" - try: - from datetime import timedelta - from django.utils import timezone - - # Get recent access logs for this user (last 7 days, limited) - seven_days_ago = timezone.now() - timedelta(days=7) - recent_logs = AccessLog.objects.filter( - user=obj.username, - timestamp__gte=seven_days_ago - ).order_by('-timestamp')[:15] # Limit to 15 most recent - - if not recent_logs: - return mark_safe('
No recent activity (last 7 days)
') - - html = '
' - - # Header - html += '
' - html += f'πŸ“Š Recent Activity ({recent_logs.count()} entries, last 7 days)' - html += '
' - - # Activity entries - for i, log in enumerate(recent_logs): - bg_color = '#ffffff' if i % 2 == 0 else '#f8f9fa' - local_time = localtime(log.timestamp) - - # Status icon and color - if log.action == 'Success': - icon = 'βœ…' - status_color = '#28a745' - elif log.action == 'Failed': - icon = '❌' - status_color = '#dc3545' - else: - icon = 'ℹ️' - status_color = '#6c757d' - - html += f'
' - - # Left side - server and link info - html += f'
' - html += f'{icon}' - html += f'
' - html += f'
{log.server}
' - - if log.acl_link_id: - link_short = log.acl_link_id[:12] + '...' if len(log.acl_link_id) > 12 else log.acl_link_id - html += f'
{link_short}
' - - html += f'
' - - # Right side - timestamp and status - html += f'
' - html += f'
{local_time.strftime("%m-%d %H:%M")}
' - html += f'
{log.action}
' - html += f'
' - - html += f'
' - - # Footer with summary if there are more entries - total_recent = AccessLog.objects.filter( - user=obj.username, - timestamp__gte=seven_days_ago - ).count() - - if total_recent > 15: - html += f'
' - html += f'Showing 15 of {total_recent} entries from last 7 days' - html += f'
' - - html += '
' - return mark_safe(html) - - except Exception as e: - return mark_safe(f'Error loading activity: {e}') - - @admin.display(description='Allowed servers', ordering='server_count') - def server_count(self, obj): - return obj.server_count - - def get_queryset(self, request): - qs = super().get_queryset(request) - qs = qs.annotate(server_count=Count('acl')) - return qs - - def get_urls(self): - """Add custom URLs for link management""" - urls = super().get_urls() - custom_urls = [ - path('/add-link/', self.admin_site.admin_view(self.add_link_view), name='user_add_link'), - path('/delete-link//', self.admin_site.admin_view(self.delete_link_view), name='user_delete_link'), - path('/add-server-access/', self.admin_site.admin_view(self.add_server_access_view), name='user_add_server_access'), - ] - return custom_urls + urls - - def add_link_view(self, request, user_id): - """AJAX view to add a new link for user on specific server""" - from django.http import JsonResponse - - if request.method == 'POST': - try: - user = User.objects.get(pk=user_id) - server_id = request.POST.get('server_id') - comment = request.POST.get('comment', '') - - if not server_id: - return JsonResponse({'error': 'Server ID is required'}, status=400) - - server = Server.objects.get(pk=server_id) - acl = ACL.objects.get(user=user, server=server) - - # Create new link - new_link = ACLLink.objects.create( - acl=acl, - comment=comment, - link=shortuuid.ShortUUID().random(length=16) - ) - - return JsonResponse({ - 'success': True, - 'link_id': new_link.id, - 'link': new_link.link, - 'comment': new_link.comment, - 'url': f"{EXTERNAL_ADDRESS}/ss/{new_link.link}#{server.name}" - }) - - except Exception as e: - return JsonResponse({'error': str(e)}, status=500) - - return JsonResponse({'error': 'Invalid request method'}, status=405) - - def delete_link_view(self, request, user_id, link_id): - """AJAX view to delete a specific link""" - from django.http import JsonResponse - - if request.method == 'POST': - try: - user = User.objects.get(pk=user_id) - link = ACLLink.objects.get(pk=link_id, acl__user=user) - link.delete() - - return JsonResponse({'success': True}) - - except Exception as e: - return JsonResponse({'error': str(e)}, status=500) - - return JsonResponse({'error': 'Invalid request method'}, status=405) - - def add_server_access_view(self, request, user_id): - """AJAX view to add server access for user""" - from django.http import JsonResponse - - if request.method == 'POST': - try: - user = User.objects.get(pk=user_id) - server_id = request.POST.get('server_id') - - if not server_id: - return JsonResponse({'error': 'Server ID is required'}, status=400) - - server = Server.objects.get(pk=server_id) - - # Check if ACL already exists - if ACL.objects.filter(user=user, server=server).exists(): - return JsonResponse({'error': 'User already has access to this server'}, status=400) - - # Create new ACL (with default link) - acl = ACL.objects.create(user=user, server=server) - - return JsonResponse({ - 'success': True, - 'server_name': server.name, - 'server_type': server.server_type, - 'acl_id': acl.id - }) - - except Exception as e: - return JsonResponse({'error': str(e)}, status=500) - - return JsonResponse({'error': 'Invalid request method'}, status=405) - - def change_view(self, request, object_id, form_url='', extra_context=None): - """Override change view to add user management data and fix layout""" - extra_context = extra_context or {} - - if object_id: - try: - user = User.objects.get(pk=object_id) - extra_context.update({ - 'user_object': user, - 'external_address': EXTERNAL_ADDRESS, - }) - - except User.DoesNotExist: - pass - - return super().change_view(request, object_id, form_url, extra_context) - - # Removed save_model as we no longer manage servers directly through the form - # Legacy VPN access is now managed through the ACL admin interface - # Xray access is managed through the UserXraySubscriptionInline - # Note: get_or_create will use the default save() method which creates default links - -@admin.register(AccessLog) -class AccessLogAdmin(admin.ModelAdmin): - list_display = ('user', 'server', 'acl_link_display', 'action', 'formatted_timestamp') - list_filter = ('user', 'server', 'action', 'timestamp') - search_fields = ('user', 'server', 'acl_link_id', 'action', 'timestamp', 'data') - readonly_fields = ('server', 'user', 'acl_link_id', 'formatted_timestamp', 'action', 'formated_data') - - @admin.display(description='Link', ordering='acl_link_id') - def acl_link_display(self, obj): - if obj.acl_link_id: - return format_html( - '{}', - obj.acl_link_id[:12] + '...' if len(obj.acl_link_id) > 12 else obj.acl_link_id - ) - return '-' - - @admin.display(description='Timestamp') - def formatted_timestamp(self, obj): - local_time = localtime(obj.timestamp) - return local_time.strftime('%Y-%m-%d %H:%M:%S %Z') - - @admin.display(description='Details') - def formated_data(self, obj): - return format_object(obj.data) - - -class ACLLinkInline(admin.TabularInline): - model = ACLLink - extra = 1 - help_text = 'Add or change ACL links' - verbose_name = 'Dynamic link' - verbose_name_plural = 'Dynamic links' - fields = ('link', 'generate_link_button', 'comment') - readonly_fields = ('generate_link_button',) - - @admin.display(description="Generate") - def generate_link_button(self, obj=None): - return format_html( - '' - ) - - class Media: - js = ('admin/js/generate_link.js',) - -@admin.register(ACL) -class ACLAdmin(admin.ModelAdmin): - - list_display = ('user', 'server', 'server_type', 'display_links', 'created_at') - list_filter = (UserNameFilter, 'server__server_type', ServerNameFilter) - # Fixed search_fields - removed problematic polymorphic server fields - search_fields = ('user__username', 'user__comment', 'links__link') - readonly_fields = ('user_info',) - inlines = [ACLLinkInline] - - @admin.display(description='Server Type', ordering='server__server_type') - def server_type(self, obj): - return obj.server.get_server_type_display() - - @admin.display(description='Client info') - def user_info(self, obj): - server = obj.server - user = obj.user - try: - # Use cached statistics instead of direct server requests - from .models import UserStatistics - user_stats = UserStatistics.objects.filter( - user=user, - server_name=server.name - ).first() - - if user_stats: - # Format cached data nicely - data = { - 'user': user.username, - 'server': server.name, - 'total_connections': user_stats.total_connections, - 'recent_connections': user_stats.recent_connections, - 'max_daily': user_stats.max_daily, - 'last_updated': user_stats.updated_at.strftime('%Y-%m-%d %H:%M:%S'), - 'status': 'from_cache' - } - return format_object(data) - else: - # Fallback to minimal server check (avoid slow API calls on admin pages) - return mark_safe( - '
' + - 'ℹ️ User Statistics:
' + - 'No cached statistics available.
' + - 'Run "Update user statistics cache" action to populate data.' + - '
' - ) - except Exception as e: - import logging - logger = logging.getLogger(__name__) - logger.error(f"Failed to get cached user info for {user.username} on {server.name}: {e}") - return mark_safe(f"Cache error: {e}") - - @admin.display(description='User Links') - def display_links(self, obj): - links_count = obj.links.count() - portal_url = f"{EXTERNAL_ADDRESS}/u/{obj.user.hash}" - - return format_html( - '
' - 'πŸ”— {} link(s)' - '
' - '🌐 User Portal', - links_count, portal_url - ) - - -# Note: UserStatistics is not registered separately as admin model. -# All user statistics functionality is integrated into ACLLinkAdmin below. -@admin.register(ACLLink) -class ACLLinkAdmin(admin.ModelAdmin): - list_display = ('link_display', 'user_display', 'server_display', 'comment_display', 'stats_display', 'usage_chart_display', 'last_access_display', 'created_display') - list_filter = ('acl__server__name', 'acl__server__server_type', LastAccessFilter, 'acl__user__username') - search_fields = ('link', 'comment', 'acl__user__username', 'acl__server__name') - list_per_page = 100 - actions = ['delete_selected_links', 'update_statistics_action'] - list_select_related = ('acl__user', 'acl__server') - - def get_queryset(self, request): - qs = super().get_queryset(request) - qs = qs.select_related('acl__user', 'acl__server') - return qs - - @admin.display(description='Link', ordering='link') - def link_display(self, obj): - link_url = f"{EXTERNAL_ADDRESS}/ss/{obj.link}#{obj.acl.server.name}" - return format_html( - '{}', - link_url, obj.link[:16] + '...' if len(obj.link) > 16 else obj.link - ) - - @admin.display(description='User', ordering='acl__user__username') - def user_display(self, obj): - return obj.acl.user.username - - @admin.display(description='Server', ordering='acl__server__name') - def server_display(self, obj): - server_type_icons = { - 'outline': 'πŸ”΅', - 'wireguard': '🟒', - 'xray_core': '🟣', - } - icon = server_type_icons.get(obj.acl.server.server_type, 'βšͺ') - return f"{icon} {obj.acl.server.name}" - - @admin.display(description='Comment', ordering='comment') - def comment_display(self, obj): - if obj.comment: - return obj.comment[:50] + '...' if len(obj.comment) > 50 else obj.comment - return '-' - - @admin.display(description='Statistics') - def stats_display(self, obj): - try: - from .models import UserStatistics - stats = UserStatistics.objects.get( - user=obj.acl.user, - server_name=obj.acl.server.name, - acl_link_id=obj.link - ) - - # Color coding based on usage - if stats.total_connections > 100: - color = '#16a34a' # green - high usage - elif stats.total_connections > 10: - color = '#eab308' # yellow - medium usage - elif stats.total_connections > 0: - color = '#f97316' # orange - low usage - else: - color = '#9ca3af' # gray - no usage - - return mark_safe( - f'
' - f'✨ {stats.total_connections} total
' - f'πŸ“… {stats.recent_connections} last 30d' - f'
' - ) - except: - return mark_safe('No cache') - - @admin.display(description='30-day Chart') - def usage_chart_display(self, obj): - try: - from .models import UserStatistics - stats = UserStatistics.objects.get( - user=obj.acl.user, - server_name=obj.acl.server.name, - acl_link_id=obj.link - ) - - if not stats.daily_usage: - return mark_safe('No data') - - # Create wider mini chart for better visibility - max_val = max(stats.daily_usage) if stats.daily_usage else 1 - chart_html = '
' - - # Show last 30 days with wider bars for better visibility - for day_count in stats.daily_usage[-30:]: # Last 30 days - if max_val > 0: - height_percent = (day_count / max_val) * 100 - else: - height_percent = 0 - - color = '#4ade80' if day_count > 0 else '#e5e7eb' - chart_html += f'
' - - chart_html += '
' - - # Add summary info below chart - total_last_30 = sum(stats.daily_usage[-30:]) if stats.daily_usage else 0 - avg_daily = total_last_30 / 30 if total_last_30 > 0 else 0 - chart_html += f'
' - chart_html += f'Max: {max_val} | Avg: {avg_daily:.1f}' - chart_html += f'
' - - return mark_safe(chart_html) - except: - return mark_safe('-') - - @admin.display(description='Last Access', ordering='last_access_time') - def last_access_display(self, obj): - if obj.last_access_time: - from django.utils import timezone - from datetime import timedelta - - local_time = localtime(obj.last_access_time) - now = timezone.now() - diff = now - obj.last_access_time - - # Color coding based on age - if diff <= timedelta(days=7): - color = '#16a34a' # green - recent - elif diff <= timedelta(days=30): - color = '#eab308' # yellow - medium - elif diff <= timedelta(days=90): - color = '#f97316' # orange - old - else: - color = '#dc2626' # red - very old - - formatted_date = local_time.strftime('%Y-%m-%d %H:%M') - - # Add relative time info - if diff.days > 365: - relative = f'{diff.days // 365}y ago' - elif diff.days > 30: - relative = f'{diff.days // 30}mo ago' - elif diff.days > 0: - relative = f'{diff.days}d ago' - elif diff.seconds > 3600: - relative = f'{diff.seconds // 3600}h ago' - else: - relative = 'Recently' - - return mark_safe( - f'{formatted_date}' - f'
{relative}' - ) - return mark_safe('Never') - - @admin.display(description='Created', ordering='acl__created_at') - def created_display(self, obj): - local_time = localtime(obj.acl.created_at) - return local_time.strftime('%Y-%m-%d %H:%M') - - def delete_selected_links(self, request, queryset): - count = queryset.count() - queryset.delete() - self.message_user(request, f'Successfully deleted {count} ACL link(s).') - delete_selected_links.short_description = "Delete selected ACL links" - - def update_statistics_action(self, request, queryset): - """Trigger comprehensive statistics update for all users and links""" - # This action doesn't require selected items - try: - from vpn.tasks import update_user_statistics - - # Start the statistics update task - task = update_user_statistics.delay() - - self.message_user( - request, - f'πŸ“Š Statistics update started successfully! Task ID: {task.id}. ' - f'This will recalculate usage statistics for all users and links. ' - f'Refresh this page in a few moments to see updated data.', - level=messages.SUCCESS - ) - - except Exception as e: - self.message_user( - request, - f'❌ Failed to start statistics update: {e}', - level=messages.ERROR - ) - update_statistics_action.short_description = "πŸ“Š Update all user statistics and usage data" - - def get_actions(self, request): - """Remove default delete action and keep only custom one""" - actions = super().get_actions(request) - if 'delete_selected' in actions: - del actions['delete_selected'] - return actions - - def has_add_permission(self, request): - return False - - def has_change_permission(self, request, obj=None): - return True - - def has_delete_permission(self, request, obj=None): - return True - - def changelist_view(self, request, extra_context=None): - # Handle actions that don't require item selection - if 'action' in request.POST: - action = request.POST['action'] - if action == 'update_statistics_action': - # Call the action directly without queryset requirement - self.update_statistics_action(request, None) - # Return redirect to prevent AttributeError - return redirect(request.get_full_path()) - - # Add comprehensive statistics to the changelist - extra_context = extra_context or {} - - # Get queryset for statistics - queryset = self.get_queryset(request) - - total_links = queryset.count() - never_accessed = queryset.filter(last_access_time__isnull=True).count() - - from django.utils import timezone - from datetime import timedelta - from django.db.models import Count, Max, Min - - now = timezone.now() - one_week_ago = now - timedelta(days=7) - one_month_ago = now - timedelta(days=30) - three_months_ago = now - timedelta(days=90) - - # Access time statistics - old_links = queryset.filter( - Q(last_access_time__lt=three_months_ago) | Q(last_access_time__isnull=True) - ).count() - - recent_week = queryset.filter(last_access_time__gte=one_week_ago).count() - recent_month = queryset.filter(last_access_time__gte=one_month_ago).count() - - # Calculate comprehensive statistics from cache - try: - from .models import UserStatistics - from django.db import models - - # Total usage statistics - cached_stats = UserStatistics.objects.aggregate( - total_uses=models.Sum('total_connections'), - recent_uses=models.Sum('recent_connections'), - max_daily_peak=models.Max('max_daily') - ) - total_uses = cached_stats['total_uses'] or 0 - recent_uses = cached_stats['recent_uses'] or 0 - max_daily_peak = cached_stats['max_daily_peak'] or 0 - - # Server and user breakdown - server_stats = UserStatistics.objects.values('server_name').annotate( - total_connections=models.Sum('total_connections'), - link_count=models.Count('id') - ).order_by('-total_connections')[:5] # Top 5 servers - - user_stats = UserStatistics.objects.values('user__username').annotate( - total_connections=models.Sum('total_connections'), - link_count=models.Count('id') - ).order_by('-total_connections')[:5] # Top 5 users - - # Links with cache data count - cached_links_count = UserStatistics.objects.filter( - acl_link_id__isnull=False - ).count() - - except Exception as e: - total_uses = 0 - recent_uses = 0 - max_daily_peak = 0 - server_stats = [] - user_stats = [] - cached_links_count = 0 - - # Active vs inactive breakdown - active_links = total_links - never_accessed - old_links - if active_links < 0: - active_links = 0 - - extra_context.update({ - 'total_links': total_links, - 'never_accessed': never_accessed, - 'old_links': old_links, - 'active_links': active_links, - 'recent_week': recent_week, - 'recent_month': recent_month, - 'total_uses': total_uses, - 'recent_uses': recent_uses, - 'max_daily_peak': max_daily_peak, - 'server_stats': server_stats, - 'user_stats': user_stats, - 'cached_links_count': cached_links_count, - 'cache_coverage_percent': (cached_links_count * 100 // total_links) if total_links > 0 else 0, - }) - - return super().changelist_view(request, extra_context) - - def get_ordering(self, request): - """Allow sorting by annotated fields""" - # Handle sorting by last_access_time if requested - order_var = request.GET.get('o') - if order_var: - try: - field_index = int(order_var.lstrip('-')) - # Check if this corresponds to the last_access column (index 6 in list_display) - if field_index == 6: # last_access_display is at index 6 - if order_var.startswith('-'): - return ['-last_access_time'] - else: - return ['last_access_time'] - except (ValueError, IndexError): - pass - - # Default ordering - return ['-acl__created_at', 'acl__user__username'] - - -try: - from django_celery_results.models import GroupResult, TaskResult - from django_celery_beat.models import ( - PeriodicTask, - ClockedSchedule, - CrontabSchedule, - IntervalSchedule, - SolarSchedule - ) - from django.contrib.auth.models import Group - - # Unregister celery models that we don't want in admin - admin.site.unregister(GroupResult) - admin.site.unregister(PeriodicTask) - admin.site.unregister(ClockedSchedule) - admin.site.unregister(CrontabSchedule) - admin.site.unregister(IntervalSchedule) - admin.site.unregister(SolarSchedule) - admin.site.unregister(TaskResult) - - # Unregister Django's default Group model - admin.site.unregister(Group) - -except (ImportError, admin.sites.NotRegistered): - pass - # Custom Celery admin interfaces try: from django_celery_results.models import TaskResult + # Unregister default TaskResult admin if it exists + try: + admin.site.unregister(TaskResult) + except admin.sites.NotRegistered: + pass + + @admin.register(TaskResult) class CustomTaskResultAdmin(admin.ModelAdmin): list_display = ('task_name_display', 'status', 'date_created', 'date_done', 'worker', 'result_display', 'traceback_display') @@ -2085,7 +111,6 @@ try: def result_display(self, obj): if obj.status == 'SUCCESS' and obj.result: try: - import json result = json.loads(obj.result) if isinstance(obj.result, str) else obj.result if isinstance(result, str): return result[:100] + '...' if len(result) > 100 else result @@ -2105,7 +130,6 @@ try: def result_formatted(self, obj): if obj.result: try: - import json result = json.loads(obj.result) if isinstance(obj.result, str) else obj.result formatted = json.dumps(result, indent=2) return mark_safe(f"
{formatted}
") @@ -2127,15 +151,21 @@ try: def has_change_permission(self, request, obj=None): return False + except ImportError: - pass - -# Register XrayServerV2 admin -admin.site.register(XrayServerV2, XrayServerV2Admin) + pass # Celery not available # Add subscription management to User admin -from django.contrib.admin import site -for model, admin_instance in site._registry.items(): - if model.__name__ == 'User' and hasattr(admin_instance, 'fieldsets'): - add_subscription_management_to_user(admin_instance.__class__) - break +try: + from vpn.admin.user import add_subscription_management_to_user + from django.contrib.admin import site + for model, admin_instance in site._registry.items(): + if model.__name__ == 'User' and hasattr(admin_instance, 'fieldsets'): + add_subscription_management_to_user(admin_instance.__class__) + break +except Exception as e: + logger.error(f"Failed to add subscription management: {e}") + +# Note: Unwanted admin interfaces are cleaned up in vpn/apps.py ready() method + +# Force reload trigger \ No newline at end of file diff --git a/vpn/admin/__init__.py b/vpn/admin/__init__.py new file mode 100644 index 0000000..28f2b0a --- /dev/null +++ b/vpn/admin/__init__.py @@ -0,0 +1,45 @@ +""" +VPN Admin Module + +This module provides Django admin interfaces for the VPN application. +The admin interface has been refactored into separate modules for better organization: + +- base.py: Common utilities and base classes +- user.py: User management admin interface +- server.py: Server management admin interface +- access.py: Access control (ACL/ACLLink) admin interfaces +- logs.py: Logging (TaskExecutionLog/AccessLog) admin interfaces + +All admin classes are automatically registered with Django admin. +""" + +# Import all admin classes to ensure they are registered +from .user import UserAdmin +from .server import ServerAdmin, UserACLInline +from .access import ( + ACLAdmin, + ACLLinkAdmin, + UserNameFilter, + ServerNameFilter, + LastAccessFilter, + ACLLinkInline +) +from .logs import TaskExecutionLogAdmin, AccessLogAdmin +from .base import BaseVPNAdmin, format_bytes + +# Re-export for backward compatibility +__all__ = [ + 'UserAdmin', + 'ServerAdmin', + 'UserACLInline', + 'ACLAdmin', + 'ACLLinkAdmin', + 'TaskExecutionLogAdmin', + 'AccessLogAdmin', + 'BaseVPNAdmin', + 'format_bytes', + 'UserNameFilter', + 'ServerNameFilter', + 'LastAccessFilter', + 'ACLLinkInline' +] \ No newline at end of file diff --git a/vpn/admin/access.py b/vpn/admin/access.py new file mode 100644 index 0000000..884fe2d --- /dev/null +++ b/vpn/admin/access.py @@ -0,0 +1,485 @@ +""" +Access control admin interfaces (ACL, ACLLink) +""" +from django.contrib import admin +from django.utils.safestring import mark_safe +from django.utils.html import format_html +from django.shortcuts import redirect +from django.contrib import messages +from django.utils.timezone import localtime +from django.db.models import Q + +from mysite.settings import EXTERNAL_ADDRESS +from vpn.models import ACL, ACLLink, User +from .base import BaseVPNAdmin +from vpn.utils import format_object + + +class UserNameFilter(admin.SimpleListFilter): + title = 'User' + parameter_name = 'user' + + def lookups(self, request, model_admin): + users = set(User.objects.values_list('username', flat=True)) + return [(user, user) for user in users] + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(user__username=self.value()) + return queryset + + +class ServerNameFilter(admin.SimpleListFilter): + title = 'Server Name' + parameter_name = 'acl__server__name' + + def lookups(self, request, model_admin): + servers = set(ACL.objects.values_list('server__name', flat=True)) + return [(server, server) for server in servers] + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(acl__server__name=self.value()) + return queryset + + +class LastAccessFilter(admin.SimpleListFilter): + title = 'Last Access' + parameter_name = 'last_access_status' + + def lookups(self, request, model_admin): + return [ + ('never', 'Never accessed'), + ('week', 'Last week'), + ('month', 'Last month'), + ('old', 'Older than 3 months'), + ] + + def queryset(self, request, queryset): + from django.utils import timezone + from datetime import timedelta + + if self.value() == 'never': + # Links that have never been accessed + return queryset.filter(last_access_time__isnull=True) + elif self.value() == 'week': + # Links accessed in the last week + week_ago = timezone.now() - timedelta(days=7) + return queryset.filter(last_access_time__gte=week_ago) + elif self.value() == 'month': + # Links accessed in the last month + month_ago = timezone.now() - timedelta(days=30) + return queryset.filter(last_access_time__gte=month_ago) + elif self.value() == 'old': + # Links not accessed for more than 3 months + three_months_ago = timezone.now() - timedelta(days=90) + return queryset.filter(last_access_time__lt=three_months_ago) + return queryset + + +class ACLLinkInline(admin.TabularInline): + model = ACLLink + extra = 1 + help_text = 'Add or change ACL links' + verbose_name = 'Dynamic link' + verbose_name_plural = 'Dynamic links' + fields = ('link', 'generate_link_button', 'comment') + readonly_fields = ('generate_link_button',) + + @admin.display(description="Generate") + def generate_link_button(self, obj=None): + return format_html( + '' + ) + + class Media: + js = ('admin/js/generate_link.js',) + + +@admin.register(ACL) +class ACLAdmin(BaseVPNAdmin): + list_display = ('user', 'server', 'server_type', 'display_links', 'created_at') + list_filter = (UserNameFilter, 'server__server_type', ServerNameFilter) + # Fixed search_fields - removed problematic polymorphic server fields + search_fields = ('user__username', 'user__comment', 'links__link') + readonly_fields = ('user_info',) + inlines = [ACLLinkInline] + + @admin.display(description='Server Type', ordering='server__server_type') + def server_type(self, obj): + return obj.server.get_server_type_display() + + @admin.display(description='Client info') + def user_info(self, obj): + server = obj.server + user = obj.user + try: + # Use cached statistics instead of direct server requests + from vpn.models import UserStatistics + user_stats = UserStatistics.objects.filter( + user=user, + server_name=server.name + ).first() + + if user_stats: + # Format cached data nicely + data = { + 'user': user.username, + 'server': server.name, + 'total_connections': user_stats.total_connections, + 'recent_connections': user_stats.recent_connections, + 'max_daily': user_stats.max_daily, + 'last_updated': user_stats.updated_at.strftime('%Y-%m-%d %H:%M:%S'), + 'status': 'from_cache' + } + return format_object(data) + else: + # Fallback to minimal server check (avoid slow API calls on admin pages) + return mark_safe( + '
' + + 'ℹ️ User Statistics:
' + + 'No cached statistics available.
' + + 'Run "Update user statistics cache" action to populate data.' + + '
' + ) + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.error(f"Failed to get cached user info for {user.username} on {server.name}: {e}") + return mark_safe(f"Cache error: {e}") + + @admin.display(description='User Links') + def display_links(self, obj): + links_count = obj.links.count() + portal_url = f"{EXTERNAL_ADDRESS}/u/{obj.user.hash}" + + return format_html( + '
' + 'πŸ”— {} link(s)' + '
' + '🌐 User Portal', + links_count, portal_url + ) + + +# Note: UserStatistics is not registered separately as admin model. +# All user statistics functionality is integrated into ACLLinkAdmin below. +@admin.register(ACLLink) +class ACLLinkAdmin(BaseVPNAdmin): + list_display = ('link_display', 'user_display', 'server_display', 'comment_display', 'stats_display', 'usage_chart_display', 'last_access_display', 'created_display') + list_filter = ('acl__server__name', 'acl__server__server_type', LastAccessFilter, 'acl__user__username') + search_fields = ('link', 'comment', 'acl__user__username', 'acl__server__name') + list_per_page = 100 + actions = ['delete_selected_links', 'update_statistics_action'] + list_select_related = ('acl__user', 'acl__server') + + def get_queryset(self, request): + qs = super().get_queryset(request) + qs = qs.select_related('acl__user', 'acl__server') + return qs + + @admin.display(description='Link', ordering='link') + def link_display(self, obj): + link_url = f"{EXTERNAL_ADDRESS}/ss/{obj.link}#{obj.acl.server.name}" + return format_html( + '{}', + link_url, obj.link[:16] + '...' if len(obj.link) > 16 else obj.link + ) + + @admin.display(description='User', ordering='acl__user__username') + def user_display(self, obj): + return obj.acl.user.username + + @admin.display(description='Server', ordering='acl__server__name') + def server_display(self, obj): + server_type_icons = { + 'outline': 'πŸ”΅', + 'wireguard': '🟒', + 'xray_core': '🟣', + } + icon = server_type_icons.get(obj.acl.server.server_type, 'βšͺ') + return f"{icon} {obj.acl.server.name}" + + @admin.display(description='Comment', ordering='comment') + def comment_display(self, obj): + if obj.comment: + return obj.comment[:50] + '...' if len(obj.comment) > 50 else obj.comment + return '-' + + @admin.display(description='Statistics') + def stats_display(self, obj): + try: + from vpn.models import UserStatistics + stats = UserStatistics.objects.get( + user=obj.acl.user, + server_name=obj.acl.server.name, + acl_link_id=obj.link + ) + + # Color coding based on usage + if stats.total_connections > 100: + color = '#16a34a' # green - high usage + elif stats.total_connections > 10: + color = '#eab308' # yellow - medium usage + elif stats.total_connections > 0: + color = '#f97316' # orange - low usage + else: + color = '#9ca3af' # gray - no usage + + return mark_safe( + f'
' + f'✨ {stats.total_connections} total
' + f'πŸ“… {stats.recent_connections} last 30d' + f'
' + ) + except: + return mark_safe('No cache') + + @admin.display(description='30-day Chart') + def usage_chart_display(self, obj): + try: + from vpn.models import UserStatistics + stats = UserStatistics.objects.get( + user=obj.acl.user, + server_name=obj.acl.server.name, + acl_link_id=obj.link + ) + + if not stats.daily_usage: + return mark_safe('No data') + + # Create wider mini chart for better visibility + max_val = max(stats.daily_usage) if stats.daily_usage else 1 + chart_html = '
' + + # Show last 30 days with wider bars for better visibility + for day_count in stats.daily_usage[-30:]: # Last 30 days + if max_val > 0: + height_percent = (day_count / max_val) * 100 + else: + height_percent = 0 + + color = '#4ade80' if day_count > 0 else '#e5e7eb' + chart_html += f'
' + + chart_html += '
' + + # Add summary info below chart + total_last_30 = sum(stats.daily_usage[-30:]) if stats.daily_usage else 0 + avg_daily = total_last_30 / 30 if total_last_30 > 0 else 0 + chart_html += f'
' + chart_html += f'Max: {max_val} | Avg: {avg_daily:.1f}' + chart_html += f'
' + + return mark_safe(chart_html) + except: + return mark_safe('-') + + @admin.display(description='Last Access', ordering='last_access_time') + def last_access_display(self, obj): + if obj.last_access_time: + from django.utils import timezone + from datetime import timedelta + + local_time = localtime(obj.last_access_time) + now = timezone.now() + diff = now - obj.last_access_time + + # Color coding based on age + if diff <= timedelta(days=7): + color = '#16a34a' # green - recent + elif diff <= timedelta(days=30): + color = '#eab308' # yellow - medium + elif diff <= timedelta(days=90): + color = '#f97316' # orange - old + else: + color = '#dc2626' # red - very old + + formatted_date = local_time.strftime('%Y-%m-%d %H:%M') + + # Add relative time info + if diff.days > 365: + relative = f'{diff.days // 365}y ago' + elif diff.days > 30: + relative = f'{diff.days // 30}mo ago' + elif diff.days > 0: + relative = f'{diff.days}d ago' + elif diff.seconds > 3600: + relative = f'{diff.seconds // 3600}h ago' + else: + relative = 'Recently' + + return mark_safe( + f'{formatted_date}' + f'
{relative}' + ) + return mark_safe('Never') + + @admin.display(description='Created', ordering='acl__created_at') + def created_display(self, obj): + local_time = localtime(obj.acl.created_at) + return local_time.strftime('%Y-%m-%d %H:%M') + + def delete_selected_links(self, request, queryset): + count = queryset.count() + queryset.delete() + self.message_user(request, f'Successfully deleted {count} ACL link(s).') + delete_selected_links.short_description = "Delete selected ACL links" + + def update_statistics_action(self, request, queryset): + """Trigger comprehensive statistics update for all users and links""" + # This action doesn't require selected items + try: + from vpn.tasks import update_user_statistics + + # Start the statistics update task + task = update_user_statistics.delay() + + self.message_user( + request, + f'πŸ“Š Statistics update started successfully! Task ID: {task.id}. ' + f'This will recalculate usage statistics for all users and links. ' + f'Refresh this page in a few moments to see updated data.', + level=messages.SUCCESS + ) + + except Exception as e: + self.message_user( + request, + f'❌ Failed to start statistics update: {e}', + level=messages.ERROR + ) + update_statistics_action.short_description = "πŸ“Š Update all user statistics and usage data" + + def get_actions(self, request): + """Remove default delete action and keep only custom one""" + actions = super().get_actions(request) + if 'delete_selected' in actions: + del actions['delete_selected'] + return actions + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return True + + def has_delete_permission(self, request, obj=None): + return True + + def changelist_view(self, request, extra_context=None): + # Handle actions that don't require item selection + if 'action' in request.POST: + action = request.POST['action'] + if action == 'update_statistics_action': + # Call the action directly without queryset requirement + self.update_statistics_action(request, None) + # Return redirect to prevent AttributeError + return redirect(request.get_full_path()) + + # Add comprehensive statistics to the changelist + extra_context = extra_context or {} + + # Get queryset for statistics + queryset = self.get_queryset(request) + + total_links = queryset.count() + never_accessed = queryset.filter(last_access_time__isnull=True).count() + + from django.utils import timezone + from datetime import timedelta + from django.db.models import Count, Max, Min + + now = timezone.now() + one_week_ago = now - timedelta(days=7) + one_month_ago = now - timedelta(days=30) + three_months_ago = now - timedelta(days=90) + + # Access time statistics + old_links = queryset.filter( + Q(last_access_time__lt=three_months_ago) | Q(last_access_time__isnull=True) + ).count() + + recent_week = queryset.filter(last_access_time__gte=one_week_ago).count() + recent_month = queryset.filter(last_access_time__gte=one_month_ago).count() + + # Calculate comprehensive statistics from cache + try: + from vpn.models import UserStatistics + from django.db import models + + # Total usage statistics + cached_stats = UserStatistics.objects.aggregate( + total_uses=models.Sum('total_connections'), + recent_uses=models.Sum('recent_connections'), + max_daily_peak=models.Max('max_daily') + ) + total_uses = cached_stats['total_uses'] or 0 + recent_uses = cached_stats['recent_uses'] or 0 + max_daily_peak = cached_stats['max_daily_peak'] or 0 + + # Server and user breakdown + server_stats = UserStatistics.objects.values('server_name').annotate( + total_connections=models.Sum('total_connections'), + link_count=models.Count('id') + ).order_by('-total_connections')[:5] # Top 5 servers + + user_stats = UserStatistics.objects.values('user__username').annotate( + total_connections=models.Sum('total_connections'), + link_count=models.Count('id') + ).order_by('-total_connections')[:5] # Top 5 users + + # Links with cache data count + cached_links_count = UserStatistics.objects.filter( + acl_link_id__isnull=False + ).count() + + except Exception as e: + total_uses = 0 + recent_uses = 0 + max_daily_peak = 0 + server_stats = [] + user_stats = [] + cached_links_count = 0 + + # Active vs inactive breakdown + active_links = total_links - never_accessed - old_links + if active_links < 0: + active_links = 0 + + extra_context.update({ + 'total_links': total_links, + 'never_accessed': never_accessed, + 'old_links': old_links, + 'active_links': active_links, + 'recent_week': recent_week, + 'recent_month': recent_month, + 'total_uses': total_uses, + 'recent_uses': recent_uses, + 'max_daily_peak': max_daily_peak, + 'server_stats': server_stats, + 'user_stats': user_stats, + 'cached_links_count': cached_links_count, + 'cache_coverage_percent': (cached_links_count * 100 // total_links) if total_links > 0 else 0, + }) + + return super().changelist_view(request, extra_context) + + def get_ordering(self, request): + """Allow sorting by annotated fields""" + # Handle sorting by last_access_time if requested + order_var = request.GET.get('o') + if order_var: + try: + field_index = int(order_var.lstrip('-')) + # Check if this corresponds to the last_access column (index 6 in list_display) + if field_index == 6: # last_access_display is at index 6 + if order_var.startswith('-'): + return ['-last_access_time'] + else: + return ['last_access_time'] + except (ValueError, IndexError): + pass + + # Default ordering + return ['-acl__created_at', 'acl__user__username'] \ No newline at end of file diff --git a/vpn/admin/base.py b/vpn/admin/base.py new file mode 100644 index 0000000..f76993d --- /dev/null +++ b/vpn/admin/base.py @@ -0,0 +1,57 @@ +""" +Base utilities and common imports for VPN admin interfaces +""" +import json +import shortuuid +from django.contrib import admin +from django.utils.safestring import mark_safe +from django.utils.html import format_html +from django.db.models import Count +from django.shortcuts import render, redirect +from django.contrib import messages +from django.urls import path, reverse +from django.http import HttpResponseRedirect, JsonResponse +from django.utils.timezone import localtime +from django.db.models import Max, Subquery, OuterRef, Q + +from mysite.settings import EXTERNAL_ADDRESS + + +def format_bytes(bytes_val): + """Format bytes to human readable format""" + for unit in ['B', 'KB', 'MB', 'GB', 'TB']: + if bytes_val < 1024.0: + return f"{bytes_val:.1f}{unit}" + bytes_val /= 1024.0 + return f"{bytes_val:.1f}PB" + + +class BaseVPNAdmin(admin.ModelAdmin): + """Base admin class with common functionality""" + + class Media: + css = { + 'all': ('admin/css/vpn_admin.css',) + } + + def get_external_address(self): + """Get external address for links""" + return EXTERNAL_ADDRESS + + def format_hash_link(self, obj, hash_value): + """Format hash as clickable link""" + if not hash_value: + return mark_safe('No hash') + + portal_url = f"https://{EXTERNAL_ADDRESS}/u/{hash_value}" + return mark_safe( + f'
' + f'{hash_value[:12]}...' + f'πŸ”— Portal' + f'
' + ) + + +class BaseListFilter(admin.SimpleListFilter): + """Base filter class with common functionality""" + pass \ No newline at end of file diff --git a/vpn/admin/logs.py b/vpn/admin/logs.py new file mode 100644 index 0000000..4d01af5 --- /dev/null +++ b/vpn/admin/logs.py @@ -0,0 +1,179 @@ +""" +Logging admin interfaces (TaskExecutionLog, AccessLog) +""" +from django.contrib import admin +from django.utils.safestring import mark_safe +from django.utils.html import format_html +from django.shortcuts import redirect +from django.contrib import messages +from django.utils.timezone import localtime + +from vpn.models import TaskExecutionLog, AccessLog +from .base import BaseVPNAdmin +from vpn.utils import format_object + + +@admin.register(TaskExecutionLog) +class TaskExecutionLogAdmin(BaseVPNAdmin): + list_display = ('task_name_display', 'action', 'status_display', 'server', 'user', 'execution_time_display', 'created_at') + list_filter = ('task_name', 'status', 'server', 'created_at') + search_fields = ('task_id', 'task_name', 'action', 'user__username', 'server__name', 'message') + readonly_fields = ('task_id', 'task_name', 'server', 'user', 'action', 'status', 'message_formatted', 'execution_time', 'created_at') + ordering = ('-created_at',) + list_per_page = 100 + date_hierarchy = 'created_at' + actions = ['trigger_full_sync', 'trigger_statistics_update'] + + fieldsets = ( + ('Task Information', { + 'fields': ('task_id', 'task_name', 'action', 'status') + }), + ('Related Objects', { + 'fields': ('server', 'user') + }), + ('Execution Details', { + 'fields': ('message_formatted', 'execution_time', 'created_at') + }), + ) + + def trigger_full_sync(self, request, queryset): + """Trigger manual full synchronization of all servers""" + # This action doesn't require selected items + try: + from vpn.tasks import sync_all_users + + # Start the sync task + task = sync_all_users.delay() + + self.message_user( + request, + f'Full synchronization started successfully. Task ID: {task.id}. Check logs below for progress.', + level=messages.SUCCESS + ) + + except Exception as e: + self.message_user( + request, + f'Failed to start full synchronization: {e}', + level=messages.ERROR + ) + + trigger_full_sync.short_description = "πŸ”„ Trigger full sync of all servers" + + def trigger_statistics_update(self, request, queryset): + """Trigger manual update of user statistics cache""" + # This action doesn't require selected items + try: + from vpn.tasks import update_user_statistics + + # Start the statistics update task + task = update_user_statistics.delay() + + self.message_user( + request, + f'User statistics update started successfully. Task ID: {task.id}. Check logs below for progress.', + level=messages.SUCCESS + ) + + except Exception as e: + self.message_user( + request, + f'Failed to start statistics update: {e}', + level=messages.ERROR + ) + + trigger_statistics_update.short_description = "πŸ“Š Update user statistics cache" + + def get_actions(self, request): + """Remove default delete action for logs""" + actions = super().get_actions(request) + if 'delete_selected' in actions: + del actions['delete_selected'] + return actions + + @admin.display(description='Task', ordering='task_name') + def task_name_display(self, obj): + task_names = { + 'sync_all_servers': 'πŸ”„ Sync All', + 'sync_server_users': 'πŸ‘₯ Server Sync', + 'sync_server_info': 'βš™οΈ Server Info', + 'sync_user_on_server': 'πŸ‘€ User Sync', + 'cleanup_task_logs': '🧹 Cleanup', + 'update_user_statistics': 'πŸ“Š Statistics', + } + return task_names.get(obj.task_name, obj.task_name) + + @admin.display(description='Status', ordering='status') + def status_display(self, obj): + status_icons = { + 'STARTED': '🟑 Started', + 'SUCCESS': 'βœ… Success', + 'FAILURE': '❌ Failed', + 'RETRY': 'πŸ”„ Retry', + } + return status_icons.get(obj.status, obj.status) + + @admin.display(description='Time', ordering='execution_time') + def execution_time_display(self, obj): + if obj.execution_time: + if obj.execution_time < 1: + return f"{obj.execution_time*1000:.0f}ms" + else: + return f"{obj.execution_time:.2f}s" + return '-' + + @admin.display(description='Message') + def message_formatted(self, obj): + if obj.message: + return mark_safe(f"
{obj.message}
") + return '-' + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + def changelist_view(self, request, extra_context=None): + """Override to handle actions that don't require item selection""" + # Handle actions that don't require selection + if 'action' in request.POST: + action = request.POST['action'] + if action == 'trigger_full_sync': + # Call the action directly without queryset requirement + self.trigger_full_sync(request, None) + # Return redirect to prevent AttributeError + return redirect(request.get_full_path()) + elif action == 'trigger_statistics_update': + # Call the statistics update action + self.trigger_statistics_update(request, None) + # Return redirect to prevent AttributeError + return redirect(request.get_full_path()) + + return super().changelist_view(request, extra_context) + + +@admin.register(AccessLog) +class AccessLogAdmin(BaseVPNAdmin): + list_display = ('user', 'server', 'acl_link_display', 'action', 'formatted_timestamp') + list_filter = ('user', 'server', 'action', 'timestamp') + search_fields = ('user', 'server', 'acl_link_id', 'action', 'timestamp', 'data') + readonly_fields = ('server', 'user', 'acl_link_id', 'formatted_timestamp', 'action', 'formated_data') + + @admin.display(description='Link', ordering='acl_link_id') + def acl_link_display(self, obj): + if obj.acl_link_id: + return format_html( + '{}', + obj.acl_link_id[:12] + '...' if len(obj.acl_link_id) > 12 else obj.acl_link_id + ) + return '-' + + @admin.display(description='Timestamp') + def formatted_timestamp(self, obj): + local_time = localtime(obj.timestamp) + return local_time.strftime('%Y-%m-%d %H:%M:%S %Z') + + @admin.display(description='Details') + def formated_data(self, obj): + return format_object(obj.data) \ No newline at end of file diff --git a/vpn/admin/server.py b/vpn/admin/server.py new file mode 100644 index 0000000..7460768 --- /dev/null +++ b/vpn/admin/server.py @@ -0,0 +1,826 @@ +""" +Server admin interface +""" +import re +from polymorphic.admin import PolymorphicParentModelAdmin +from django.contrib import admin +from django.utils.safestring import mark_safe +from django.utils.html import format_html +from django.db.models import Count, Case, When, Value, IntegerField, F, Subquery, OuterRef +from django.shortcuts import render, redirect, get_object_or_404 +from django.contrib import messages +from django.urls import path, reverse +from django.http import HttpResponseRedirect, JsonResponse + +from mysite.settings import EXTERNAL_ADDRESS +from vpn.models import Server, ACL, ACLLink +from .base import BaseVPNAdmin, format_bytes +from vpn.server_plugins import ( + OutlineServer, + WireguardServer, + XrayServerV2 +) + + +@admin.register(Server) +class ServerAdmin(PolymorphicParentModelAdmin, BaseVPNAdmin): + base_model = Server + child_models = (OutlineServer, WireguardServer, XrayServerV2) + list_display = ('name_with_icon', 'server_type', 'comment_short', 'user_stats', 'server_status_compact', 'registration_date') + search_fields = ('name', 'comment') + list_filter = ('server_type', ) + actions = ['move_clients_action', 'purge_all_keys_action', 'sync_all_selected_servers', 'sync_xray_inbounds', 'check_status'] + + class Media: + css = { + 'all': ('admin/css/vpn_admin.css',) + } + js = ('admin/js/server_status_check.js',) + + def get_urls(self): + urls = super().get_urls() + custom_urls = [ + path('move-clients/', self.admin_site.admin_view(self.move_clients_view), name='server_move_clients'), + path('/check-status/', self.admin_site.admin_view(self.check_server_status_view), name='server_check_status'), + path('/sync/', self.admin_site.admin_view(self.sync_server_view), name='server_sync'), + ] + return custom_urls + urls + + def move_clients_action(self, request, queryset): + """Custom action to move client links between servers""" + if queryset.count() == 0: + self.message_user(request, "Please select at least one server.", level=messages.ERROR) + return + + # Redirect to move clients page + selected_ids = ','.join(str(server.id) for server in queryset) + return HttpResponseRedirect(f"{reverse('admin:server_move_clients')}?servers={selected_ids}") + + move_clients_action.short_description = "Move client links between servers" + + def move_clients_view(self, request): + """View for moving clients between servers""" + if request.method == 'GET': + # Get selected servers from URL parameters + server_ids = request.GET.get('servers', '').split(',') + if not server_ids or server_ids == ['']: + messages.error(request, "No servers selected.") + return redirect('admin:vpn_server_changelist') + + try: + # Only work with database objects, don't check server connectivity + servers = Server.objects.filter(id__in=server_ids) + all_servers = Server.objects.all() + + # Get ACL links for selected servers with related data + # This is purely database operation, no server connectivity required + links_by_server = {} + for server in servers: + try: + # Get all ACL links for this server with user and ACL data + links = ACLLink.objects.filter( + acl__server=server + ).select_related('acl__user', 'acl__server').order_by('acl__user__username', 'comment') + links_by_server[server] = links + except Exception as e: + # Log the error but continue with other servers + messages.warning(request, f"Warning: Could not load links for server {server.name}: {e}") + links_by_server[server] = [] + + context = { + 'title': 'Move Client Links Between Servers', + 'servers': servers, + 'all_servers': all_servers, + 'links_by_server': links_by_server, + } + + return render(request, 'admin/move_clients.html', context) + + except Exception as e: + messages.error(request, f"Database error while loading data: {e}") + return redirect('admin:vpn_server_changelist') + + elif request.method == 'POST': + # Process the transfer of ACL links - purely database operations + try: + source_server_id = request.POST.get('source_server') + target_server_id = request.POST.get('target_server') + selected_link_ids = request.POST.getlist('selected_links') + comment_regex = request.POST.get('comment_regex', '').strip() + + if not source_server_id or not target_server_id: + messages.error(request, "Please select both source and target servers.") + return redirect(request.get_full_path()) + + if source_server_id == target_server_id: + messages.error(request, "Source and target servers cannot be the same.") + return redirect(request.get_full_path()) + + if not selected_link_ids: + messages.error(request, "Please select at least one link to move.") + return redirect(request.get_full_path()) + + # Parse and validate regex pattern if provided + regex_pattern = None + regex_replacement = None + regex_parts = None + if comment_regex: + try: + regex_parts = comment_regex.split(' -> ') + if len(regex_parts) != 2: + messages.error(request, "Invalid regex format. Use: pattern -> replacement") + return redirect(request.get_full_path()) + + pattern_str = regex_parts[0] + replacement_str = regex_parts[1] + + # Convert JavaScript-style $1, $2, $3 to Python-style \1, \2, \3 + python_replacement = replacement_str + # Replace $1, $2, etc. with \1, \2, etc. for Python regex + python_replacement = re.sub(r'\$(\d+)', r'\\\1', replacement_str) + + # Test compile the regex pattern + regex_pattern = re.compile(pattern_str) + regex_replacement = python_replacement + + # Test the replacement on a sample string to validate syntax + test_result = regex_pattern.sub(regex_replacement, "test sample") + + except re.error as e: + messages.error(request, f"Invalid regular expression pattern '{regex_parts[0] if regex_parts else comment_regex}': {e}") + return redirect(request.get_full_path()) + except Exception as e: + messages.error(request, f"Error in regex replacement '{replacement_str if 'replacement_str' in locals() else 'unknown'}': {e}") + return redirect(request.get_full_path()) + + # Get server objects from database only + try: + source_server = Server.objects.get(id=source_server_id) + target_server = Server.objects.get(id=target_server_id) + except Server.DoesNotExist: + messages.error(request, "One of the selected servers was not found in database.") + return redirect('admin:vpn_server_changelist') + + moved_count = 0 + errors = [] + users_processed = set() + comments_transformed = 0 + + # Process each selected link - database operations only + for link_id in selected_link_ids: + try: + # Get the ACL link with related ACL and user data + acl_link = ACLLink.objects.select_related('acl__user', 'acl__server').get( + id=link_id, + acl__server=source_server + ) + user = acl_link.acl.user + + # Apply regex transformation to comment if provided + original_comment = acl_link.comment + if regex_pattern and regex_replacement is not None: + try: + # Use Python's re.sub for replacement, which properly handles $1, $2 groups + new_comment = regex_pattern.sub(regex_replacement, original_comment) + if new_comment != original_comment: + acl_link.comment = new_comment + comments_transformed += 1 + # Debug logging - shows both original and converted patterns + print(f"DEBUG: Transformed '{original_comment}' -> '{new_comment}'") + print(f" Original pattern: '{regex_parts[0]}' -> '{regex_parts[1]}'") + print(f" Python pattern: '{regex_parts[0]}' -> '{regex_replacement}'") + except Exception as e: + errors.append(f"Error applying regex to link {link_id} ('{original_comment}'): {e}") + # Continue with original comment + + # Check if user already has ACL on target server + target_acl = ACL.objects.filter(user=user, server=target_server).first() + + if target_acl: + created = False + else: + # Create new ACL without auto-creating default link + target_acl = ACL(user=user, server=target_server) + target_acl.save(auto_create_link=False) + created = True + + # Move the link to target ACL - pure database operation + acl_link.acl = target_acl + acl_link.save() + + moved_count += 1 + users_processed.add(user.username) + + if created: + messages.info(request, f"Created new ACL for user {user.username} on server {target_server.name}") + + except ACLLink.DoesNotExist: + errors.append(f"Link with ID {link_id} not found on source server") + except Exception as e: + errors.append(f"Database error moving link {link_id}: {e}") + + # Clean up empty ACLs on source server - database operation only + try: + empty_acls = ACL.objects.filter( + server=source_server, + links__isnull=True + ) + deleted_acls_count = empty_acls.count() + empty_acls.delete() + except Exception as e: + messages.warning(request, f"Warning: Could not clean up empty ACLs: {e}") + deleted_acls_count = 0 + + if moved_count > 0: + success_msg = ( + f"Successfully moved {moved_count} link(s) for {len(users_processed)} user(s) " + f"from '{source_server.name}' to '{target_server.name}'. " + f"Cleaned up {deleted_acls_count} empty ACL(s)." + ) + if comments_transformed > 0: + success_msg += f" Transformed {comments_transformed} comment(s) using regex." + + messages.success(request, success_msg) + + if errors: + for error in errors: + messages.error(request, error) + + return redirect('admin:vpn_server_changelist') + + except Exception as e: + messages.error(request, f"Database error during link transfer: {e}") + return redirect('admin:vpn_server_changelist') + + def check_server_status_view(self, request, server_id): + """AJAX view to check server status""" + import logging + + logger = logging.getLogger(__name__) + + if request.method == 'POST': + try: + logger.info(f"Checking status for server ID: {server_id}") + server = Server.objects.get(pk=server_id) + real_server = server.get_real_instance() + logger.info(f"Server found: {server.name}, type: {type(real_server).__name__}") + + # Check server status based on type + from vpn.server_plugins.outline import OutlineServer + # Old xray_core module removed - skip this server type + + if isinstance(real_server, OutlineServer): + try: + logger.info(f"Checking Outline server: {server.name}") + # Try to get server info to check if it's online + info = real_server.client.get_server_information() + if info: + logger.info(f"Server {server.name} is online with {info.get('accessKeyCount', info.get('access_key_count', 'unknown'))} keys") + return JsonResponse({ + 'success': True, + 'status': 'online', + 'message': f'Server is online. Keys: {info.get("accessKeyCount", info.get("access_key_count", "unknown"))}' + }) + else: + logger.warning(f"Server {server.name} returned no info") + return JsonResponse({ + 'success': True, + 'status': 'offline', + 'message': 'Server not responding' + }) + except Exception as e: + logger.error(f"Error checking Outline server {server.name}: {e}") + return JsonResponse({ + 'success': True, + 'status': 'error', + 'message': f'Connection error: {str(e)[:100]}' + }) + + elif isinstance(real_server, XrayServerV2): + try: + logger.info(f"Checking Xray v2 server: {server.name}") + # Get server status from new Xray implementation + status = real_server.get_server_status() + if status and isinstance(status, dict): + if status.get('accessible', False): + message = f'βœ… Server is {status.get("status", "accessible")}. ' + message += f'Host: {status.get("client_hostname", "N/A")}, ' + message += f'API: {status.get("api_address", "N/A")}' + + if status.get('api_connected'): + message += ' (Connected)' + # Add stats if available + api_stats = status.get('api_stats', {}) + if api_stats and isinstance(api_stats, dict): + if 'connection' in api_stats: + message += f', Stats: {api_stats.get("connection", "ok")}' + if api_stats.get('library') == 'not_available': + message += ' [Basic check only]' + elif status.get('api_error'): + message += f' ({status.get("api_error")})' + + message += f', Inbounds: {status.get("total_inbounds", 0)}' + + logger.info(f"Xray v2 server {server.name} status: {message}") + return JsonResponse({ + 'success': True, + 'status': 'online', + 'message': message + }) + else: + error_msg = status.get('error') or status.get('api_error', 'Unknown error') + logger.warning(f"Xray v2 server {server.name} not accessible: {error_msg}") + return JsonResponse({ + 'success': True, + 'status': 'offline', + 'message': f'❌ Server not accessible: {error_msg}' + }) + else: + logger.warning(f"Xray v2 server {server.name} returned invalid status") + return JsonResponse({ + 'success': True, + 'status': 'offline', + 'message': 'Invalid server response' + }) + except Exception as e: + logger.error(f"Error checking Xray v2 server {server.name}: {e}") + return JsonResponse({ + 'success': True, + 'status': 'error', + 'message': f'Connection error: {str(e)[:100]}' + }) + + else: + # For other server types, just return basic info + logger.info(f"Server {server.name}, type: {server.server_type}") + return JsonResponse({ + 'success': True, + 'status': 'unknown', + 'message': f'Status check not implemented for {server.server_type} servers' + }) + + except Server.DoesNotExist: + logger.error(f"Server with ID {server_id} not found") + return JsonResponse({ + 'success': False, + 'error': 'Server not found' + }, status=404) + except Exception as e: + logger.error(f"Unexpected error checking server {server_id}: {e}") + return JsonResponse({ + 'success': False, + 'error': f'Unexpected error: {str(e)}' + }, status=500) + + logger.warning(f"Invalid request method {request.method} for server status check") + return JsonResponse({ + 'success': False, + 'error': 'Invalid request method' + }, status=405) + + def purge_all_keys_action(self, request, queryset): + """Purge all keys from selected servers without changing database""" + if queryset.count() == 0: + self.message_user(request, "Please select at least one server.", level=messages.ERROR) + return + + success_count = 0 + error_count = 0 + total_keys_removed = 0 + + for server in queryset: + try: + # Get the real polymorphic instance + real_server = server.get_real_instance() + server_type = type(real_server).__name__ + + # Check if this is an Outline server + from vpn.server_plugins.outline import OutlineServer + + if isinstance(real_server, OutlineServer) and hasattr(real_server, 'client'): + # For Outline servers, get all keys and delete them + try: + keys = real_server.client.get_keys() + keys_count = len(keys) + + for key in keys: + try: + real_server.client.delete_key(key.key_id) + except Exception as e: + self.message_user( + request, + f"Failed to delete key {key.key_id} from {server.name}: {e}", + level=messages.WARNING + ) + + total_keys_removed += keys_count + success_count += 1 + self.message_user( + request, + f"Successfully purged {keys_count} keys from server '{server.name}'.", + level=messages.SUCCESS + ) + + except Exception as e: + error_count += 1 + self.message_user( + request, + f"Failed to connect to server '{server.name}': {e}", + level=messages.ERROR + ) + else: + self.message_user( + request, + f"Key purging only supported for Outline servers. Skipping '{server.name}' (type: {server_type}).", + level=messages.INFO + ) + + except Exception as e: + error_count += 1 + self.message_user( + request, + f"Unexpected error with server '{server.name}': {e}", + level=messages.ERROR + ) + + # Summary message + if success_count > 0: + self.message_user( + request, + f"Purge completed! {success_count} server(s) processed, {total_keys_removed} total keys removed. " + f"Database unchanged - run sync to restore proper keys.", + level=messages.SUCCESS + ) + + if error_count > 0: + self.message_user( + request, + f"{error_count} server(s) had errors during purge.", + level=messages.WARNING + ) + + purge_all_keys_action.short_description = "πŸ—‘οΈ Purge all keys from server (database unchanged)" + + def sync_all_selected_servers(self, request, queryset): + """Trigger sync for all users on selected servers""" + if queryset.count() == 0: + self.message_user(request, "Please select at least one server.", level=messages.ERROR) + return + + try: + from vpn.tasks import sync_server_users + + tasks_started = 0 + errors = [] + + for server in queryset: + try: + task = sync_server_users.delay(server.id) + tasks_started += 1 + self.message_user( + request, + f"πŸ”„ Sync task started for '{server.name}' (Task ID: {task.id})", + level=messages.SUCCESS + ) + except Exception as e: + errors.append(f"'{server.name}': {e}") + + if tasks_started > 0: + self.message_user( + request, + f"βœ… Started sync tasks for {tasks_started} server(s). Check Task Execution Logs for progress.", + level=messages.SUCCESS + ) + + if errors: + for error in errors: + self.message_user( + request, + f"❌ Failed to sync {error}", + level=messages.ERROR + ) + + except Exception as e: + self.message_user( + request, + f"❌ Failed to start sync tasks: {e}", + level=messages.ERROR + ) + + sync_all_selected_servers.short_description = "πŸ”„ Sync all users on selected servers" + + def check_status(self, request, queryset): + """Check status for selected servers""" + for server in queryset: + try: + status = server.get_server_status() + msg = f"{server.name}: {status.get('accessible', 'Unknown')} - {status.get('status', 'N/A')}" + self.message_user(request, msg, level=messages.INFO) + except Exception as e: + self.message_user(request, f"Error checking {server.name}: {e}", level=messages.ERROR) + check_status.short_description = "πŸ“Š Check server status" + + def sync_xray_inbounds(self, request, queryset): + """Sync inbounds for selected servers (Xray v2 only)""" + synced_count = 0 + + for server in queryset: + try: + real_server = server.get_real_instance() + if isinstance(real_server, XrayServerV2): + real_server.sync_inbounds() + synced_count += 1 + self.message_user(request, f"Scheduled inbound sync for {server.name}", level=messages.SUCCESS) + else: + self.message_user(request, f"{server.name} is not an Xray v2 server", level=messages.WARNING) + except Exception as e: + self.message_user(request, f"Error syncing inbounds for {server.name}: {e}", level=messages.ERROR) + + if synced_count > 0: + self.message_user(request, f"Scheduled inbound sync for {synced_count} server(s)", level=messages.SUCCESS) + sync_xray_inbounds.short_description = "πŸ”§ Sync Xray inbounds" + + @admin.display(description='Server', ordering='name') + def name_with_icon(self, obj): + """Display server name with type icon""" + icons = { + 'outline': 'πŸ”΅', + 'wireguard': '🟒', + 'xray_core': '🟣', + 'xray_v2': '🟑', + } + icon = icons.get(obj.server_type, '') + name_part = f"{icon} {obj.name}" if icon else obj.name + return name_part + + @admin.display(description='Comment') + def comment_short(self, obj): + """Display shortened comment""" + if obj.comment: + short_comment = obj.comment[:40] + '...' if len(obj.comment) > 40 else obj.comment + return mark_safe(f'{short_comment}') + return '-' + + @admin.display(description='Users & Links') + def user_stats(self, obj): + """Display user count and active links statistics (optimized)""" + try: + from django.utils import timezone + from datetime import timedelta + + user_count = obj.user_count if hasattr(obj, 'user_count') else 0 + + # Different logic for Xray vs legacy servers + if obj.server_type == 'xray_v2': + # For Xray servers, count inbounds and active subscriptions + from vpn.models_xray import ServerInbound + total_inbounds = ServerInbound.objects.filter(server=obj, active=True).count() + + # Count recent subscription accesses via AccessLog + thirty_days_ago = timezone.now() - timedelta(days=30) + from vpn.models import AccessLog + active_accesses = AccessLog.objects.filter( + server='Xray-Subscription', + action='Success', + timestamp__gte=thirty_days_ago + ).values('user').distinct().count() + + total_links = total_inbounds + active_links = min(active_accesses, user_count) # Can't be more than total users + else: + # Legacy servers: use ACL links as before + if hasattr(obj, 'acl_set'): + all_links = [] + for acl in obj.acl_set.all(): + if hasattr(acl, 'links') and hasattr(acl.links, 'all'): + all_links.extend(acl.links.all()) + + total_links = len(all_links) + + # Count active links from prefetched data + thirty_days_ago = timezone.now() - timedelta(days=30) + active_links = sum(1 for link in all_links + if link.last_access_time and link.last_access_time >= thirty_days_ago) + else: + # Fallback to direct queries (less efficient) + total_links = ACLLink.objects.filter(acl__server=obj).count() + thirty_days_ago = timezone.now() - timedelta(days=30) + active_links = ACLLink.objects.filter( + acl__server=obj, + last_access_time__isnull=False, + last_access_time__gte=thirty_days_ago + ).count() + + # Color coding based on activity + if user_count == 0: + color = '#9ca3af' # gray - no users + elif total_links == 0: + color = '#dc2626' # red - no links/inbounds + elif obj.server_type == 'xray_v2': + # For Xray: base on user activity rather than link activity + if active_links > user_count * 0.5: # More than half users active + color = '#16a34a' # green + elif active_links > user_count * 0.2: # More than 20% users active + color = '#eab308' # yellow + else: + color = '#f97316' # orange - low activity + else: + # Legacy servers: base on link activity + if total_links > 0 and active_links > total_links * 0.7: # High activity + color = '#16a34a' # green + elif total_links > 0 and active_links > total_links * 0.3: # Medium activity + color = '#eab308' # yellow + else: + color = '#f97316' # orange - low activity + + # Different display for Xray vs legacy + if obj.server_type == 'xray_v2': + # Try to get traffic stats if stats enabled + traffic_info = "" + # Get the real XrayServerV2 instance to access its fields + xray_server = obj.get_real_instance() + if hasattr(xray_server, 'stats_enabled') and xray_server.stats_enabled and xray_server.api_enabled: + try: + from vpn.xray_api_v2.client import XrayClient + from vpn.xray_api_v2.stats import StatsManager + + client = XrayClient(server=xray_server.api_address) + stats_manager = StatsManager(client) + traffic_summary = stats_manager.get_traffic_summary() + + # Calculate total traffic + total_uplink = 0 + total_downlink = 0 + + # Sum up user traffic + for user_email, user_traffic in traffic_summary.get('users', {}).items(): + total_uplink += user_traffic.get('uplink', 0) + total_downlink += user_traffic.get('downlink', 0) + + # Format traffic + + if total_uplink > 0 or total_downlink > 0: + traffic_info = f'
↑{format_bytes(total_uplink)} ↓{format_bytes(total_downlink)}
' + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.debug(f"Could not get stats for Xray server {xray_server.name}: {e}") + + return mark_safe( + f'
' + + f'
πŸ‘₯ {user_count} users
' + + f'
πŸ“‘ {total_links} inbounds
' + + traffic_info + + f'
' + ) + else: + return mark_safe( + f'
' + + f'
πŸ‘₯ {user_count} users
' + + f'
πŸ”— {active_links}/{total_links} active
' + + f'
' + ) + except Exception as e: + import traceback + import logging + logger = logging.getLogger(__name__) + logger.error(f"Error in user_stats for server {obj.name}: {e}", exc_info=True) + return mark_safe(f'Stats error: {e}') + + @admin.display(description='Activity') + def activity_summary(self, obj): + """Display recent activity summary (optimized)""" + try: + # Simplified version - avoid heavy DB queries on list page + # This could be computed once per page load if needed + return mark_safe( + f'
' + + f'
πŸ“Š Activity data
' + + f'
Click to view details
' + + f'
' + ) + except Exception as e: + return mark_safe(f'Activity unavailable') + + @admin.display(description='Status') + def server_status_compact(self, obj): + """Display server status in compact format (optimized)""" + try: + # Avoid expensive server connectivity checks on list page + # Show basic info and let users click to check status + server_type_icons = { + 'outline': 'πŸ”΅', + 'wireguard': '🟒', + 'xray_core': '🟣', + } + icon = server_type_icons.get(obj.server_type, 'βšͺ') + + return mark_safe( + f'
' + + f'{icon} {obj.server_type.title()}
' + + f'' + + f'
' + ) + except Exception as e: + return mark_safe( + f'
' + + f'⚠️ Error
' + + f'' + + f'{str(e)[:25]}...' + + f'
' + ) + + def get_queryset(self, request): + from vpn.models_xray import UserSubscription, ServerInbound + + qs = super().get_queryset(request) + + # Count ACL users for all servers + qs = qs.annotate( + acl_user_count=Count('acl__user', distinct=True) + ) + + # For Xray servers, calculate user count separately + # Create subquery to count Xray users + xray_user_count_subquery = ServerInbound.objects.filter( + server_id=OuterRef('pk'), + active=True, + inbound__subscriptiongroup__usersubscription__active=True, + inbound__subscriptiongroup__is_active=True + ).values('server_id').annotate( + count=Count('inbound__subscriptiongroup__usersubscription__user', distinct=True) + ).values('count') + + qs = qs.annotate( + xray_user_count=Subquery(xray_user_count_subquery, output_field=IntegerField()), + user_count=Case( + When(server_type='xray_v2', then=F('xray_user_count')), + default=F('acl_user_count'), + output_field=IntegerField() + ) + ) + + # Handle None values from subquery + qs = qs.annotate( + user_count=Case( + When(server_type='xray_v2', user_count__isnull=True, then=Value(0)), + When(server_type='xray_v2', then=F('xray_user_count')), + default=F('acl_user_count'), + output_field=IntegerField() + ) + ) + + qs = qs.prefetch_related( + 'acl_set__links', + 'acl_set__user' + ) + return qs + + def sync_server_view(self, request, object_id): + """Dispatch sync to appropriate server type.""" + try: + server = get_object_or_404(Server, pk=object_id) + real_server = server.get_real_instance() + + # Handle XrayServerV2 + if isinstance(real_server, XrayServerV2): + return redirect(f'/admin/vpn/xrayserverv2/{real_server.pk}/sync/') + + # Fallback for other server types + else: + messages.info(request, f"Sync not implemented for server type: {real_server.server_type}") + return redirect('admin:vpn_server_changelist') + + except Exception as e: + messages.error(request, f"Error during sync: {e}") + return redirect('admin:vpn_server_changelist') + + +# Inline for legacy VPN access (Outline/Wireguard) +class UserACLInline(admin.TabularInline): + model = ACL + extra = 0 + fields = ('server', 'created_at', 'link_count') + readonly_fields = ('created_at', 'link_count') + verbose_name = "Legacy VPN Server Access" + verbose_name_plural = "Legacy VPN Server Access (Outline/Wireguard)" + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if db_field.name == "server": + # Only show old-style servers (Outline/Wireguard) + kwargs["queryset"] = Server.objects.exclude(server_type='xray_v2') + return super().formfield_for_foreignkey(db_field, request, **kwargs) + + @admin.display(description='Links') + def link_count(self, obj): + count = obj.links.count() + return format_html( + '{} link(s)', + count + ) \ No newline at end of file diff --git a/vpn/admin/user.py b/vpn/admin/user.py new file mode 100644 index 0000000..58d5531 --- /dev/null +++ b/vpn/admin/user.py @@ -0,0 +1,604 @@ +""" +User admin interface +""" +import shortuuid +from django.contrib import admin +from django.utils.safestring import mark_safe +from django.utils.html import format_html +from django.db.models import Count +from django.shortcuts import get_object_or_404 +from django.contrib import messages +from django.urls import path, reverse +from django.http import HttpResponseRedirect, JsonResponse +from django.utils.timezone import localtime + +from mysite.settings import EXTERNAL_ADDRESS +from vpn.models import User, ACL, ACLLink, Server, AccessLog, UserStatistics +from vpn.forms import UserForm +from .base import BaseVPNAdmin, format_bytes + + +@admin.register(User) +class UserAdmin(BaseVPNAdmin): + form = UserForm + list_display = ('username', 'comment', 'registration_date', 'hash_link', 'server_count') + search_fields = ('username', 'hash', 'telegram_user_id', 'telegram_username') + readonly_fields = ('hash_link', 'vpn_access_summary', 'user_statistics_summary', 'telegram_info_display') + inlines = [] # All VPN access info is now in vpn_access_summary + + fieldsets = ( + ('User Information', { + 'fields': ('username', 'first_name', 'last_name', 'email', 'comment') + }), + ('Telegram Integration', { + 'fields': ('telegram_username', 'telegram_info_display'), + 'classes': ('collapse',), + 'description': 'Link existing users to Telegram by setting telegram_username (without @)' + }), + ('Access Information', { + 'fields': ('hash_link', 'is_active', 'vpn_access_summary') + }), + ('Statistics & Server Management', { + 'fields': ('user_statistics_summary',), + 'classes': ('wide',) + }), + ) + + @admin.display(description='VPN Access Summary') + def vpn_access_summary(self, obj): + """Display summary of user's VPN access""" + if not obj.pk: + return "Save user first to see VPN access" + + # Get legacy VPN access + acl_count = ACL.objects.filter(user=obj).count() + legacy_links = ACLLink.objects.filter(acl__user=obj).count() + + # Get Xray access + from vpn.models_xray import UserSubscription + xray_subs = UserSubscription.objects.filter(user=obj, active=True).select_related('subscription_group') + xray_groups = [sub.subscription_group.name for sub in xray_subs] + + html = '
' + + # Legacy VPN section + html += '
' + html += '

πŸ“‘ Legacy VPN (Outline/Wireguard)

' + if acl_count > 0: + html += f'

βœ… Access to {acl_count} server(s)

' + html += f'

πŸ”— Total links: {legacy_links}

' + else: + html += '

No legacy VPN access

' + html += '
' + + # Xray section + html += '
' + html += '

πŸš€ Xray VPN

' + if xray_groups: + html += f'

βœ… Active subscriptions: {len(xray_groups)}

' + html += '
    ' + for group in xray_groups: + html += f'
  • {group}
  • ' + html += '
' + + # Try to get traffic statistics for this user + try: + from vpn.server_plugins.xray_v2 import XrayServerV2 + traffic_total_up = 0 + traffic_total_down = 0 + servers_checked = set() + + # Get all Xray servers + xray_servers = XrayServerV2.objects.filter(api_enabled=True, stats_enabled=True) + + for server in xray_servers: + if server.name not in servers_checked: + try: + from vpn.xray_api_v2.client import XrayClient + from vpn.xray_api_v2.stats import StatsManager + + client = XrayClient(server=server.api_address) + stats_manager = StatsManager(client) + + # Get user stats (use email format: username@servername) + user_email = f"{obj.username}@{server.name}" + user_stats = stats_manager.get_user_stats(user_email) + + if user_stats: + traffic_total_up += user_stats.get('uplink', 0) + traffic_total_down += user_stats.get('downlink', 0) + + servers_checked.add(server.name) + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.debug(f"Could not get user stats from server {server.name}: {e}") + + # Format traffic if we got any + if traffic_total_up > 0 or traffic_total_down > 0: + + html += f'

πŸ“Š Traffic Statistics:

' + html += f'

↑ Upload: {format_bytes(traffic_total_up)}

' + html += f'

↓ Download: {format_bytes(traffic_total_down)}

' + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.debug(f"Could not get traffic stats for user {obj.username}: {e}") + else: + html += '

No Xray subscriptions

' + html += '
' + + html += '
' + + return format_html(html) + + @admin.display(description='User Portal', ordering='hash') + def hash_link(self, obj): + portal_url = f"{EXTERNAL_ADDRESS}/u/{obj.hash}" + json_url = f"{EXTERNAL_ADDRESS}/stat/{obj.hash}" + return format_html( + '
' + + '🌐 Portal' + + 'πŸ“„ JSON' + + '
', + portal_url, json_url + ) + + @admin.display(description='User Statistics Summary') + def user_statistics_summary(self, obj): + """Display user statistics with integrated server management""" + try: + from vpn.models import UserStatistics + from django.db import models + + # Get statistics for this user + user_stats = UserStatistics.objects.filter(user=obj).aggregate( + total_connections=models.Sum('total_connections'), + recent_connections=models.Sum('recent_connections'), + total_links=models.Count('id'), + max_daily_peak=models.Max('max_daily') + ) + + # Get server breakdown + server_breakdown = UserStatistics.objects.filter(user=obj).values('server_name').annotate( + connections=models.Sum('total_connections'), + links=models.Count('id') + ).order_by('-connections') + + # Get all ACLs and links for this user + user_acls = ACL.objects.filter(user=obj).select_related('server').prefetch_related('links') + + # Get available servers not yet assigned + all_servers = Server.objects.all() + assigned_server_ids = [acl.server.id for acl in user_acls] + unassigned_servers = all_servers.exclude(id__in=assigned_server_ids) + + html = '
' + + # Overall Statistics + html += '
' + html += f'
' + html += f'
Total Uses: {user_stats["total_connections"] or 0}
' + html += f'
Recent (30d): {user_stats["recent_connections"] or 0}
' + html += f'
Total Links: {user_stats["total_links"] or 0}
' + if user_stats["max_daily_peak"]: + html += f'
Daily Peak: {user_stats["max_daily_peak"]}
' + html += f'
' + html += '
' + + # Server Management + if user_acls: + html += '

πŸ”— Server Access & Links

' + + for acl in user_acls: + server = acl.server + links = list(acl.links.all()) + + # Server header (no slow server status checks) + # Determine server type icon and label + if server.server_type == 'Outline': + type_icon = 'πŸ”΅' + type_label = 'Outline' + elif server.server_type == 'Wireguard': + type_icon = '🟒' + type_label = 'Wireguard' + elif server.server_type in ['xray_core', 'xray_v2']: + type_icon = '🟣' + type_label = 'Xray' + else: + type_icon = '❓' + type_label = server.server_type + + html += f'
' + html += f'
{type_icon} {server.name} ({type_label})
' + + # Server stats + server_stat = next((s for s in server_breakdown if s['server_name'] == server.name), None) + if server_stat: + html += f'' + html += f'πŸ“Š {server_stat["connections"]} uses ({server_stat["links"]} links)' + html += f'' + html += f'
' + + html += '
' + + # Links display + if links: + for link in links: + # Get link stats + link_stats = UserStatistics.objects.filter( + user=obj, server_name=server.name, acl_link_id=link.link + ).first() + + html += '' + + # Add link button + html += f'
' + html += f'' + html += f'
' + + html += '
' # End server-section + + # Add server access section + if unassigned_servers: + html += '
' + html += '
βž• Available Servers
' + html += '
' + for server in unassigned_servers: + # Determine server type icon and label + if server.server_type == 'Outline': + type_icon = 'πŸ”΅' + type_label = 'Outline' + elif server.server_type == 'Wireguard': + type_icon = '🟒' + type_label = 'Wireguard' + elif server.server_type in ['xray_core', 'xray_v2']: + type_icon = '🟣' + type_label = 'Xray' + else: + type_icon = '❓' + type_label = server.server_type + + html += f'' + html += '
' + + html += '
' # End user-management-section + return mark_safe(html) + + except Exception as e: + return mark_safe(f'Error loading management interface: {e}') + + @admin.display(description='Recent Activity') + def recent_activity_display(self, obj): + """Display recent activity in compact admin-friendly format""" + try: + from datetime import timedelta + from django.utils import timezone + + # Get recent access logs for this user (last 7 days, limited) + seven_days_ago = timezone.now() - timedelta(days=7) + recent_logs = AccessLog.objects.filter( + user=obj.username, + timestamp__gte=seven_days_ago + ).order_by('-timestamp')[:15] # Limit to 15 most recent + + if not recent_logs: + return mark_safe('
No recent activity (last 7 days)
') + + html = '
' + + # Header + html += '
' + html += f'πŸ“Š Recent Activity ({recent_logs.count()} entries, last 7 days)' + html += '
' + + # Activity entries + for i, log in enumerate(recent_logs): + bg_color = '#ffffff' if i % 2 == 0 else '#f8f9fa' + local_time = localtime(log.timestamp) + + # Status icon and color + if log.action == 'Success': + icon = 'βœ…' + status_color = '#28a745' + elif log.action == 'Failed': + icon = '❌' + status_color = '#dc3545' + else: + icon = 'ℹ️' + status_color = '#6c757d' + + html += f'
' + + # Left side - server and link info + html += f'
' + html += f'{icon}' + html += f'
' + html += f'
{log.server}
' + + if log.acl_link_id: + link_short = log.acl_link_id[:12] + '...' if len(log.acl_link_id) > 12 else log.acl_link_id + html += f'
{link_short}
' + + html += f'
' + + # Right side - timestamp and status + html += f'
' + html += f'
{local_time.strftime("%m-%d %H:%M")}
' + html += f'
{log.action}
' + html += f'
' + + html += f'
' + + # Footer with summary if there are more entries + total_recent = AccessLog.objects.filter( + user=obj.username, + timestamp__gte=seven_days_ago + ).count() + + if total_recent > 15: + html += f'
' + html += f'Showing 15 of {total_recent} entries from last 7 days' + html += f'
' + + html += '
' + return mark_safe(html) + + except Exception as e: + return mark_safe(f'Error loading activity: {e}') + + @admin.display(description='Telegram Account') + def telegram_info_display(self, obj): + """Display Telegram account information""" + if not obj.telegram_user_id: + if obj.telegram_username: + return mark_safe(f'
' + f'πŸ”— Ready to link: @{obj.telegram_username}
' + f'User will be automatically linked when they message the bot
') + else: + return mark_safe('No Telegram account linked') + + html = '
' + html += '

πŸ“± Telegram Account Information

' + + # Telegram User ID + html += f'

User ID: {obj.telegram_user_id}

' + + # Telegram Username + if obj.telegram_username: + html += f'

Username: @{obj.telegram_username}

' + + # Telegram Names + name_parts = [] + if obj.telegram_first_name: + name_parts.append(obj.telegram_first_name) + if obj.telegram_last_name: + name_parts.append(obj.telegram_last_name) + + if name_parts: + full_name = ' '.join(name_parts) + html += f'

Name: {full_name}

' + + # Telegram Phone (if available) + if obj.telegram_phone: + html += f'

Phone: {obj.telegram_phone}

' + + # Access requests count (if any) + try: + from telegram_bot.models import AccessRequest + requests_count = AccessRequest.objects.filter(telegram_user_id=obj.telegram_user_id).count() + if requests_count > 0: + html += f'

πŸ“ Access Requests: {requests_count}

' + + # Show latest request status + latest_request = AccessRequest.objects.filter(telegram_user_id=obj.telegram_user_id).order_by('-created_at').first() + if latest_request: + status_color = '#28a745' if latest_request.approved else '#ffc107' + status_text = 'Approved' if latest_request.approved else 'Pending' + html += f'

Latest: {status_text}

' + except: + pass # Telegram bot app might not be available + + # Add unlink button + unlink_url = reverse('admin:vpn_user_unlink_telegram', args=[obj.pk]) + html += f'' + + html += '
' + return mark_safe(html) + + @admin.display(description='Allowed servers', ordering='server_count') + def server_count(self, obj): + return obj.server_count + + def get_queryset(self, request): + qs = super().get_queryset(request) + qs = qs.annotate(server_count=Count('acl')) + return qs + + def get_urls(self): + """Add custom URLs for link management""" + urls = super().get_urls() + custom_urls = [ + path('/add-link/', self.admin_site.admin_view(self.add_link_view), name='user_add_link'), + path('/delete-link//', self.admin_site.admin_view(self.delete_link_view), name='user_delete_link'), + path('/add-server-access/', self.admin_site.admin_view(self.add_server_access_view), name='user_add_server_access'), + path('/unlink-telegram/', self.admin_site.admin_view(self.unlink_telegram_view), name='vpn_user_unlink_telegram'), + ] + return custom_urls + urls + + def add_link_view(self, request, user_id): + """AJAX view to add a new link for user on specific server""" + if request.method == 'POST': + try: + user = User.objects.get(pk=user_id) + server_id = request.POST.get('server_id') + comment = request.POST.get('comment', '') + + if not server_id: + return JsonResponse({'error': 'Server ID is required'}, status=400) + + server = Server.objects.get(pk=server_id) + acl = ACL.objects.get(user=user, server=server) + + # Create new link + new_link = ACLLink.objects.create( + acl=acl, + comment=comment, + link=shortuuid.ShortUUID().random(length=16) + ) + + return JsonResponse({ + 'success': True, + 'link_id': new_link.id, + 'link': new_link.link, + 'comment': new_link.comment, + 'url': f"{EXTERNAL_ADDRESS}/ss/{new_link.link}#{server.name}" + }) + + except Exception as e: + return JsonResponse({'error': str(e)}, status=500) + + return JsonResponse({'error': 'Invalid request method'}, status=405) + + def delete_link_view(self, request, user_id, link_id): + """AJAX view to delete a specific link""" + if request.method == 'POST': + try: + user = User.objects.get(pk=user_id) + link = ACLLink.objects.get(pk=link_id, acl__user=user) + link.delete() + + return JsonResponse({'success': True}) + + except Exception as e: + return JsonResponse({'error': str(e)}, status=500) + + return JsonResponse({'error': 'Invalid request method'}, status=405) + + def add_server_access_view(self, request, user_id): + """AJAX view to add server access for user""" + if request.method == 'POST': + try: + user = User.objects.get(pk=user_id) + server_id = request.POST.get('server_id') + + if not server_id: + return JsonResponse({'error': 'Server ID is required'}, status=400) + + server = Server.objects.get(pk=server_id) + + # Check if ACL already exists + if ACL.objects.filter(user=user, server=server).exists(): + return JsonResponse({'error': 'User already has access to this server'}, status=400) + + # Create new ACL (with default link) + acl = ACL.objects.create(user=user, server=server) + + return JsonResponse({ + 'success': True, + 'server_name': server.name, + 'server_type': server.server_type, + 'acl_id': acl.id + }) + + except Exception as e: + return JsonResponse({'error': str(e)}, status=500) + + return JsonResponse({'error': 'Invalid request method'}, status=405) + + def unlink_telegram_view(self, request, user_id): + """Unlink Telegram account from user""" + user = get_object_or_404(User, pk=user_id) + + if request.method == 'GET': + # Store original Telegram info for logging + telegram_info = { + 'user_id': user.telegram_user_id, + 'username': user.telegram_username, + 'first_name': user.telegram_first_name, + 'last_name': user.telegram_last_name + } + + # Clear all Telegram fields + user.telegram_user_id = None + user.telegram_username = "" + user.telegram_first_name = "" + user.telegram_last_name = "" + user.telegram_phone = "" + user.save() + + # Also clean up any related access requests + try: + from telegram_bot.models import AccessRequest + AccessRequest.objects.filter(telegram_user_id=telegram_info['user_id']).delete() + except: + pass # Telegram bot app might not be available + + messages.success( + request, + f"Telegram account {'@' + telegram_info['username'] if telegram_info['username'] else telegram_info['user_id']} " + f"has been unlinked from user '{user.username}'" + ) + + return HttpResponseRedirect(reverse('admin:vpn_user_change', args=[user_id])) + + def change_view(self, request, object_id, form_url='', extra_context=None): + """Override change view to add user management data and fix layout""" + extra_context = extra_context or {} + + if object_id: + try: + user = User.objects.get(pk=object_id) + extra_context.update({ + 'user_object': user, + 'external_address': EXTERNAL_ADDRESS, + }) + + except User.DoesNotExist: + pass + + return super().change_view(request, object_id, form_url, extra_context) \ No newline at end of file diff --git a/vpn/admin_minimal.py b/vpn/admin_minimal.py new file mode 100644 index 0000000..4549623 --- /dev/null +++ b/vpn/admin_minimal.py @@ -0,0 +1,73 @@ +""" +Minimal admin test to check execution +""" + +import logging +logger = logging.getLogger(__name__) +import json +from django.contrib import admin +from django.utils.safestring import mark_safe + +# Try importing server plugins +try: + from .server_plugins import ( + XrayServerV2, + XrayServerV2Admin + ) +except Exception as e: + logger.error(f"πŸ”΄ Failed to import server plugins: {e}") + +# Try importing refactored admin modules +try: + from .admin import * +except Exception as e: + logger.error(f"πŸ”΄ Failed to import refactored admin modules: {e}") + import traceback + logger.error(f"Traceback: {traceback.format_exc()}") + +# Try importing Xray admin classes +try: + from .admin_xray import * +except Exception as e: + logger.error(f"πŸ”΄ Failed to import Xray admin classes: {e}") + import traceback + logger.error(f"Traceback: {traceback.format_exc()}") + +# Set custom admin site configuration +admin.site.site_title = "VPN Manager" +admin.site.site_header = "VPN Manager" +admin.site.index_title = "OutFleet" + +# Try adding custom Celery admin interfaces +try: + from django_celery_results.models import TaskResult + + # Unregister default TaskResult admin if it exists + try: + admin.site.unregister(TaskResult) + except admin.sites.NotRegistered: + pass + + + @admin.register(TaskResult) + class CustomTaskResultAdmin(admin.ModelAdmin): + list_display = ('task_name_display', 'status', 'date_created') + + @admin.display(description='Task Name', ordering='task_name') + def task_name_display(self, obj): + return obj.task_name + + +except ImportError: + pass # Celery not available + +# Add subscription management to User admin +try: + from vpn.admin.user import add_subscription_management_to_user + from django.contrib.admin import site + for model, admin_instance in site._registry.items(): + if model.__name__ == 'User' and hasattr(admin_instance, 'fieldsets'): + add_subscription_management_to_user(admin_instance.__class__) + break +except Exception as e: + logger.error(f"Failed to add subscription management: {e}") \ No newline at end of file diff --git a/vpn/admin_test.py b/vpn/admin_test.py new file mode 100644 index 0000000..54bd1a8 --- /dev/null +++ b/vpn/admin_test.py @@ -0,0 +1,16 @@ +""" +Test admin file to check if code execution works +""" + +import logging +logger = logging.getLogger(__name__) +logger.info("πŸ§ͺ TEST ADMIN FILE EXECUTING!") + +from django.contrib import admin +from .models import User + +@admin.register(User) +class TestUserAdmin(admin.ModelAdmin): + list_display = ('username',) + +logger.info("πŸ§ͺ TEST ADMIN FILE COMPLETED!") \ No newline at end of file diff --git a/vpn/admin_xray.py b/vpn/admin_xray.py index 96f96a0..8f62cbb 100644 --- a/vpn/admin_xray.py +++ b/vpn/admin_xray.py @@ -3,6 +3,27 @@ Admin interface for new Xray models. """ import json +import logging + +logger = logging.getLogger(__name__) + +# Export all admin classes for import * +__all__ = [ + 'CredentialsAdmin', + 'CredentialsHiddenAdmin', + 'CertificateAdmin', + 'CertificateTabAdmin', + 'InboundAdmin', + 'InboundTabAdmin', + 'SubscriptionGroupAdmin', + 'UnifiedXRayAdmin', + 'UserSubscriptionAdmin', + 'UserSubscriptionTabAdmin', + 'ServerInboundAdmin', + 'InboundInline', + 'UserSubscriptionInline', + 'add_subscription_management_to_user' +] from django.contrib import admin, messages from django.utils.safestring import mark_safe from django.utils.html import format_html @@ -12,10 +33,16 @@ from django.shortcuts import render, redirect from django.urls import path, reverse from django.http import JsonResponse, HttpResponseRedirect -from .models_xray import ( - Credentials, Certificate, - Inbound, SubscriptionGroup, UserSubscription, ServerInbound -) +try: + from .models_xray import ( + Credentials, Certificate, + Inbound, SubscriptionGroup, UserSubscription, ServerInbound + ) +except Exception as e: + logger.error(f"Failed to import Xray models: {e}") + import traceback + logger.error(f"Traceback: {traceback.format_exc()}") + raise @@ -110,7 +137,6 @@ class CredentialsHiddenAdmin(CredentialsAdmin): return False -@admin.register(Certificate) class CertificateAdmin(admin.ModelAdmin): """Admin for certificate management""" list_display = ( @@ -445,7 +471,6 @@ class CertificateAdmin(admin.ModelAdmin): rotate_selected_certificates.short_description = "πŸ”„ Rotate selected Let's Encrypt certificates" -@admin.register(Inbound) class InboundAdmin(admin.ModelAdmin): """Admin for inbound template management""" list_display = ( @@ -673,10 +698,10 @@ class ServerInboundAdmin(admin.ModelAdmin): certificate_info.short_description = 'Certificate Selection Info' -# Unified Subscriptions Admin with tabs +# Unified XRay-core Admin with tabs @admin.register(SubscriptionGroup) -class UnifiedSubscriptionsAdmin(admin.ModelAdmin): - """Unified admin for managing both Subscription Groups and User Subscriptions""" +class UnifiedXRayAdmin(admin.ModelAdmin): + """Unified admin for managing XRay-core: Subscription Groups, User Subscriptions, Certificates, and Inbound Templates""" # Use SubscriptionGroup as the base model but provide access to UserSubscription via tabs list_display = ('name', 'is_active', 'inbound_count', 'user_count', 'created_at') @@ -685,12 +710,18 @@ class UnifiedSubscriptionsAdmin(admin.ModelAdmin): filter_horizontal = ('inbounds',) def get_urls(self): - """Add custom URLs for user subscriptions tab""" + """Add custom URLs for additional tabs""" urls = super().get_urls() custom_urls = [ path('user-subscriptions/', self.admin_site.admin_view(self.user_subscriptions_view), name='vpn_usersubscription_changelist_tab'), + path('certificates/', + self.admin_site.admin_view(self.certificates_view), + name='vpn_certificate_changelist_tab'), + path('inbound-templates/', + self.admin_site.admin_view(self.inbound_templates_view), + name='vpn_inbound_changelist_tab'), ] return custom_urls + urls @@ -699,12 +730,28 @@ class UnifiedSubscriptionsAdmin(admin.ModelAdmin): from django.shortcuts import redirect return redirect('/admin/vpn/usersubscription/') + def certificates_view(self, request): + """Redirect to certificates with tab navigation""" + from django.shortcuts import redirect + return redirect('/admin/vpn/certificate/') + + def inbound_templates_view(self, request): + """Redirect to inbound templates with tab navigation""" + from django.shortcuts import redirect + return redirect('/admin/vpn/inbound/') + def changelist_view(self, request, extra_context=None): """Override changelist to add tab navigation""" extra_context = extra_context or {} extra_context.update({ 'show_tab_navigation': True, - 'current_tab': 'subscription_groups' + 'current_tab': 'subscription_groups', + 'tabs': [ + {'name': 'subscription_groups', 'label': 'Subscription Groups', 'url': '/admin/vpn/subscriptiongroup/', 'active': True}, + {'name': 'user_subscriptions', 'label': 'User Subscriptions', 'url': '/admin/vpn/subscriptiongroup/user-subscriptions/', 'active': False}, + {'name': 'certificates', 'label': 'Certificates', 'url': '/admin/vpn/subscriptiongroup/certificates/', 'active': False}, + {'name': 'inbound_templates', 'label': 'Inbound Templates', 'url': '/admin/vpn/subscriptiongroup/inbound-templates/', 'active': False}, + ] }) return super().changelist_view(request, extra_context) @@ -713,7 +760,13 @@ class UnifiedSubscriptionsAdmin(admin.ModelAdmin): extra_context = extra_context or {} extra_context.update({ 'show_tab_navigation': True, - 'current_tab': 'subscription_groups' + 'current_tab': 'subscription_groups', + 'tabs': [ + {'name': 'subscription_groups', 'label': 'Subscription Groups', 'url': '/admin/vpn/subscriptiongroup/', 'active': True}, + {'name': 'user_subscriptions', 'label': 'User Subscriptions', 'url': '/admin/vpn/subscriptiongroup/user-subscriptions/', 'active': False}, + {'name': 'certificates', 'label': 'Certificates', 'url': '/admin/vpn/subscriptiongroup/certificates/', 'active': False}, + {'name': 'inbound_templates', 'label': 'Inbound Templates', 'url': '/admin/vpn/subscriptiongroup/inbound-templates/', 'active': False}, + ] }) return super().change_view(request, object_id, form_url, extra_context) @@ -722,7 +775,13 @@ class UnifiedSubscriptionsAdmin(admin.ModelAdmin): extra_context = extra_context or {} extra_context.update({ 'show_tab_navigation': True, - 'current_tab': 'subscription_groups' + 'current_tab': 'subscription_groups', + 'tabs': [ + {'name': 'subscription_groups', 'label': 'Subscription Groups', 'url': '/admin/vpn/subscriptiongroup/', 'active': True}, + {'name': 'user_subscriptions', 'label': 'User Subscriptions', 'url': '/admin/vpn/subscriptiongroup/user-subscriptions/', 'active': False}, + {'name': 'certificates', 'label': 'Certificates', 'url': '/admin/vpn/subscriptiongroup/certificates/', 'active': False}, + {'name': 'inbound_templates', 'label': 'Inbound Templates', 'url': '/admin/vpn/subscriptiongroup/inbound-templates/', 'active': False}, + ] }) return super().add_view(request, form_url, extra_context) @@ -810,7 +869,13 @@ class UserSubscriptionTabAdmin(UserSubscriptionAdmin): extra_context = extra_context or {} extra_context.update({ 'show_tab_navigation': True, - 'current_tab': 'user_subscriptions' + 'current_tab': 'user_subscriptions', + 'tabs': [ + {'name': 'subscription_groups', 'label': 'Subscription Groups', 'url': '/admin/vpn/subscriptiongroup/', 'active': False}, + {'name': 'user_subscriptions', 'label': 'User Subscriptions', 'url': '/admin/vpn/subscriptiongroup/user-subscriptions/', 'active': True}, + {'name': 'certificates', 'label': 'Certificates', 'url': '/admin/vpn/subscriptiongroup/certificates/', 'active': False}, + {'name': 'inbound_templates', 'label': 'Inbound Templates', 'url': '/admin/vpn/subscriptiongroup/inbound-templates/', 'active': False}, + ] }) return super().changelist_view(request, extra_context) @@ -819,7 +884,13 @@ class UserSubscriptionTabAdmin(UserSubscriptionAdmin): extra_context = extra_context or {} extra_context.update({ 'show_tab_navigation': True, - 'current_tab': 'user_subscriptions' + 'current_tab': 'user_subscriptions', + 'tabs': [ + {'name': 'subscription_groups', 'label': 'Subscription Groups', 'url': '/admin/vpn/subscriptiongroup/', 'active': False}, + {'name': 'user_subscriptions', 'label': 'User Subscriptions', 'url': '/admin/vpn/subscriptiongroup/user-subscriptions/', 'active': True}, + {'name': 'certificates', 'label': 'Certificates', 'url': '/admin/vpn/subscriptiongroup/certificates/', 'active': False}, + {'name': 'inbound_templates', 'label': 'Inbound Templates', 'url': '/admin/vpn/subscriptiongroup/inbound-templates/', 'active': False}, + ] }) return super().change_view(request, object_id, form_url, extra_context) @@ -828,6 +899,157 @@ class UserSubscriptionTabAdmin(UserSubscriptionAdmin): extra_context = extra_context or {} extra_context.update({ 'show_tab_navigation': True, - 'current_tab': 'user_subscriptions' + 'current_tab': 'user_subscriptions', + 'tabs': [ + {'name': 'subscription_groups', 'label': 'Subscription Groups', 'url': '/admin/vpn/subscriptiongroup/', 'active': False}, + {'name': 'user_subscriptions', 'label': 'User Subscriptions', 'url': '/admin/vpn/subscriptiongroup/user-subscriptions/', 'active': True}, + {'name': 'certificates', 'label': 'Certificates', 'url': '/admin/vpn/subscriptiongroup/certificates/', 'active': False}, + {'name': 'inbound_templates', 'label': 'Inbound Templates', 'url': '/admin/vpn/subscriptiongroup/inbound-templates/', 'active': False}, + ] }) - return super().add_view(request, form_url, extra_context) \ No newline at end of file + return super().add_view(request, form_url, extra_context) + + +# Certificate admin with tab navigation (hidden from main menu) +@admin.register(Certificate) +class CertificateTabAdmin(CertificateAdmin): + """Certificate admin with tab navigation""" + + def has_module_permission(self, request): + """Hide this model from the main admin index""" + return False + + def has_view_permission(self, request, obj=None): + """Allow viewing through direct URL access""" + return True + + def has_add_permission(self, request): + """Allow adding through direct URL access""" + return True + + def has_change_permission(self, request, obj=None): + """Allow changing through direct URL access""" + return True + + def has_delete_permission(self, request, obj=None): + """Allow deleting through direct URL access""" + return True + + def changelist_view(self, request, extra_context=None): + """Override changelist to add tab navigation""" + extra_context = extra_context or {} + extra_context.update({ + 'show_tab_navigation': True, + 'current_tab': 'certificates', + 'tabs': [ + {'name': 'subscription_groups', 'label': 'Subscription Groups', 'url': '/admin/vpn/subscriptiongroup/', 'active': False}, + {'name': 'user_subscriptions', 'label': 'User Subscriptions', 'url': '/admin/vpn/subscriptiongroup/user-subscriptions/', 'active': False}, + {'name': 'certificates', 'label': 'Certificates', 'url': '/admin/vpn/subscriptiongroup/certificates/', 'active': True}, + {'name': 'inbound_templates', 'label': 'Inbound Templates', 'url': '/admin/vpn/subscriptiongroup/inbound-templates/', 'active': False}, + ] + }) + return super().changelist_view(request, extra_context) + + def change_view(self, request, object_id, form_url='', extra_context=None): + """Override change view to add tab navigation""" + extra_context = extra_context or {} + extra_context.update({ + 'show_tab_navigation': True, + 'current_tab': 'certificates', + 'tabs': [ + {'name': 'subscription_groups', 'label': 'Subscription Groups', 'url': '/admin/vpn/subscriptiongroup/', 'active': False}, + {'name': 'user_subscriptions', 'label': 'User Subscriptions', 'url': '/admin/vpn/subscriptiongroup/user-subscriptions/', 'active': False}, + {'name': 'certificates', 'label': 'Certificates', 'url': '/admin/vpn/subscriptiongroup/certificates/', 'active': True}, + {'name': 'inbound_templates', 'label': 'Inbound Templates', 'url': '/admin/vpn/subscriptiongroup/inbound-templates/', 'active': False}, + ] + }) + return super().change_view(request, object_id, form_url, extra_context) + + def add_view(self, request, form_url='', extra_context=None): + """Override add view to add tab navigation""" + extra_context = extra_context or {} + extra_context.update({ + 'show_tab_navigation': True, + 'current_tab': 'certificates', + 'tabs': [ + {'name': 'subscription_groups', 'label': 'Subscription Groups', 'url': '/admin/vpn/subscriptiongroup/', 'active': False}, + {'name': 'user_subscriptions', 'label': 'User Subscriptions', 'url': '/admin/vpn/subscriptiongroup/user-subscriptions/', 'active': False}, + {'name': 'certificates', 'label': 'Certificates', 'url': '/admin/vpn/subscriptiongroup/certificates/', 'active': True}, + {'name': 'inbound_templates', 'label': 'Inbound Templates', 'url': '/admin/vpn/subscriptiongroup/inbound-templates/', 'active': False}, + ] + }) + return super().add_view(request, form_url, extra_context) + + +# Inbound admin with tab navigation (hidden from main menu) +@admin.register(Inbound) +class InboundTabAdmin(InboundAdmin): + """Inbound admin with tab navigation""" + + def has_module_permission(self, request): + """Hide this model from the main admin index""" + return False + + def has_view_permission(self, request, obj=None): + """Allow viewing through direct URL access""" + return True + + def has_add_permission(self, request): + """Allow adding through direct URL access""" + return True + + def has_change_permission(self, request, obj=None): + """Allow changing through direct URL access""" + return True + + def has_delete_permission(self, request, obj=None): + """Allow deleting through direct URL access""" + return True + + def changelist_view(self, request, extra_context=None): + """Override changelist to add tab navigation""" + extra_context = extra_context or {} + extra_context.update({ + 'show_tab_navigation': True, + 'current_tab': 'inbound_templates', + 'tabs': [ + {'name': 'subscription_groups', 'label': 'Subscription Groups', 'url': '/admin/vpn/subscriptiongroup/', 'active': False}, + {'name': 'user_subscriptions', 'label': 'User Subscriptions', 'url': '/admin/vpn/subscriptiongroup/user-subscriptions/', 'active': False}, + {'name': 'certificates', 'label': 'Certificates', 'url': '/admin/vpn/subscriptiongroup/certificates/', 'active': False}, + {'name': 'inbound_templates', 'label': 'Inbound Templates', 'url': '/admin/vpn/subscriptiongroup/inbound-templates/', 'active': True}, + ] + }) + return super().changelist_view(request, extra_context) + + def change_view(self, request, object_id, form_url='', extra_context=None): + """Override change view to add tab navigation""" + extra_context = extra_context or {} + extra_context.update({ + 'show_tab_navigation': True, + 'current_tab': 'inbound_templates', + 'tabs': [ + {'name': 'subscription_groups', 'label': 'Subscription Groups', 'url': '/admin/vpn/subscriptiongroup/', 'active': False}, + {'name': 'user_subscriptions', 'label': 'User Subscriptions', 'url': '/admin/vpn/subscriptiongroup/user-subscriptions/', 'active': False}, + {'name': 'certificates', 'label': 'Certificates', 'url': '/admin/vpn/subscriptiongroup/certificates/', 'active': False}, + {'name': 'inbound_templates', 'label': 'Inbound Templates', 'url': '/admin/vpn/subscriptiongroup/inbound-templates/', 'active': True}, + ] + }) + return super().change_view(request, object_id, form_url, extra_context) + + def add_view(self, request, form_url='', extra_context=None): + """Override add view to add tab navigation""" + extra_context = extra_context or {} + extra_context.update({ + 'show_tab_navigation': True, + 'current_tab': 'inbound_templates', + 'tabs': [ + {'name': 'subscription_groups', 'label': 'Subscription Groups', 'url': '/admin/vpn/subscriptiongroup/', 'active': False}, + {'name': 'user_subscriptions', 'label': 'User Subscriptions', 'url': '/admin/vpn/subscriptiongroup/user-subscriptions/', 'active': False}, + {'name': 'certificates', 'label': 'Certificates', 'url': '/admin/vpn/subscriptiongroup/certificates/', 'active': False}, + {'name': 'inbound_templates', 'label': 'Inbound Templates', 'url': '/admin/vpn/subscriptiongroup/inbound-templates/', 'active': True}, + ] + }) + return super().add_view(request, form_url, extra_context) + + +# Log successful completion of admin registration diff --git a/vpn/apps.py b/vpn/apps.py index 762f102..4c0f0d7 100644 --- a/vpn/apps.py +++ b/vpn/apps.py @@ -7,7 +7,103 @@ class VPN(AppConfig): def ready(self): """Import signals when Django starts""" + import sys + import logging + logger = logging.getLogger(__name__) + + logger.info(f"VPN App ready() called in process: {' '.join(sys.argv)}") + try: import vpn.signals # noqa except ImportError: pass + + # Only load admin interfaces in web processes, not in worker/beat + skip_admin_load = any([ + 'worker' in sys.argv, + 'beat' in sys.argv, + 'makemigrations' in sys.argv, + 'migrate' in sys.argv, + 'shell' in sys.argv, + 'test' in sys.argv, + ]) + + if not skip_admin_load: + logger.info("VPN App: Loading admin interfaces in web process") + # Force load admin interfaces first + self._load_admin_interfaces() + + # Clean up unwanted admin interfaces + self._cleanup_admin_interfaces() + else: + logger.info("VPN App: Skipping admin loading in non-web process") + + def _cleanup_admin_interfaces(self): + """Remove unwanted admin interfaces after all apps are loaded""" + from django.contrib import admin + import logging + logger = logging.getLogger(__name__) + + logger.info("VPN App: Starting admin cleanup...") + + try: + from django_celery_results.models import GroupResult + from django_celery_beat.models import ( + PeriodicTask, + ClockedSchedule, + CrontabSchedule, + IntervalSchedule, + SolarSchedule + ) + from django.contrib.auth.models import Group + + # Unregister celery models that we don't want in admin + models_to_unregister = [ + GroupResult, PeriodicTask, ClockedSchedule, + CrontabSchedule, IntervalSchedule, SolarSchedule + ] + + for model in models_to_unregister: + try: + admin.site.unregister(model) + logger.info(f"VPN App: Unregistered {model.__name__}") + except admin.sites.NotRegistered: + logger.debug(f"VPN App: {model.__name__} was not registered, skipping") + + # Unregister Django's default Group model + try: + admin.site.unregister(Group) + logger.info("VPN App: Unregistered Django Group model") + except admin.sites.NotRegistered: + logger.debug("VPN App: Django Group was not registered, skipping") + + except ImportError as e: + # Celery packages not installed + logger.warning(f"VPN App: Celery packages not available: {e}") + + logger.info("VPN App: Admin cleanup completed") + + def _load_admin_interfaces(self): + """Force load admin interfaces to ensure they are registered""" + import logging + logger = logging.getLogger(__name__) + + logger.info("VPN App: Force loading admin interfaces...") + + try: + # Import admin module to trigger registration + import sys + if 'vpn.admin_minimal' in sys.modules: + # Module already imported, remove it to force fresh import + del sys.modules['vpn.admin_minimal'] + logger.info("VPN App: Removed vpn.admin_minimal from cache") + + import vpn.admin_minimal + logger.info("VPN App: Successfully loaded vpn.admin_minimal") + + except Exception as e: + logger.error(f"VPN App: Failed to load vpn.admin: {e}") + import traceback + logger.error(f"VPN App: Traceback: {traceback.format_exc()}") + + logger.info("VPN App: Admin loading completed") diff --git a/vpn/migrations/0025_user_telegram_first_name_user_telegram_last_name_and_more.py b/vpn/migrations/0025_user_telegram_first_name_user_telegram_last_name_and_more.py new file mode 100644 index 0000000..da5ea1c --- /dev/null +++ b/vpn/migrations/0025_user_telegram_first_name_user_telegram_last_name_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 5.1.7 on 2025-08-14 12:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vpn', '0024_add_certificate_to_serverinbound'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='telegram_first_name', + field=models.CharField(blank=True, help_text='First name from Telegram', max_length=255, null=True), + ), + migrations.AddField( + model_name='user', + name='telegram_last_name', + field=models.CharField(blank=True, help_text='Last name from Telegram', max_length=255, null=True), + ), + migrations.AddField( + model_name='user', + name='telegram_phone', + field=models.CharField(blank=True, help_text='Phone number from Telegram (optional)', max_length=20, null=True), + ), + migrations.AddField( + model_name='user', + name='telegram_user_id', + field=models.BigIntegerField(blank=True, help_text='Telegram user ID', null=True, unique=True), + ), + migrations.AddField( + model_name='user', + name='telegram_username', + field=models.CharField(blank=True, help_text='Telegram username (without @)', max_length=255, null=True), + ), + ] diff --git a/vpn/migrations/0026_alter_subscriptiongroup_options.py b/vpn/migrations/0026_alter_subscriptiongroup_options.py new file mode 100644 index 0000000..f120e19 --- /dev/null +++ b/vpn/migrations/0026_alter_subscriptiongroup_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.7 on 2025-08-15 00:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('vpn', '0025_user_telegram_first_name_user_telegram_last_name_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='subscriptiongroup', + options={'ordering': ['name'], 'verbose_name': 'XRay-core', 'verbose_name_plural': 'XRay-core'}, + ), + ] diff --git a/vpn/models.py b/vpn/models.py index cc681e1..db1a2ef 100644 --- a/vpn/models.py +++ b/vpn/models.py @@ -94,6 +94,38 @@ class User(AbstractUser): servers = models.ManyToManyField('Server', through='ACL', blank=True, help_text="Servers user has access to") last_access = models.DateTimeField(null=True, blank=True) hash = models.CharField(max_length=64, unique=True, help_text="Random user hash. It's using for client config generation.") + + # Telegram fields + telegram_user_id = models.BigIntegerField( + null=True, + blank=True, + unique=True, + help_text="Telegram user ID" + ) + telegram_username = models.CharField( + max_length=255, + blank=True, + null=True, + help_text="Telegram username (without @)" + ) + telegram_first_name = models.CharField( + max_length=255, + blank=True, + null=True, + help_text="First name from Telegram" + ) + telegram_last_name = models.CharField( + max_length=255, + blank=True, + null=True, + help_text="Last name from Telegram" + ) + telegram_phone = models.CharField( + max_length=20, + blank=True, + null=True, + help_text="Phone number from Telegram (optional)" + ) def get_servers(self): return Server.objects.filter(acl__user=self) diff --git a/vpn/models_xray.py b/vpn/models_xray.py index ca21a9d..5a0d8ad 100644 --- a/vpn/models_xray.py +++ b/vpn/models_xray.py @@ -362,8 +362,8 @@ class SubscriptionGroup(models.Model): updated_at = models.DateTimeField(auto_now=True) class Meta: - verbose_name = "Subscriptions" - verbose_name_plural = "Subscriptions" + verbose_name = "XRay-core" + verbose_name_plural = "XRay-core" ordering = ['name'] def __str__(self): diff --git a/vpn/server_plugins/xray_v2.py b/vpn/server_plugins/xray_v2.py index 8f579c0..b8c802e 100644 --- a/vpn/server_plugins/xray_v2.py +++ b/vpn/server_plugins/xray_v2.py @@ -1,6 +1,7 @@ import logging from django.db import models from django.contrib import admin +from polymorphic.admin import PolymorphicChildModelAdmin from .generic import Server from vpn.models_xray import Inbound, UserSubscription @@ -778,7 +779,9 @@ class ServerInboundInline(admin.TabularInline): return super().formfield_for_foreignkey(db_field, request, **kwargs) -class XrayServerV2Admin(admin.ModelAdmin): +class XrayServerV2Admin(PolymorphicChildModelAdmin): + base_model = XrayServerV2 + show_in_index = False list_display = ['name', 'client_hostname', 'api_address', 'api_enabled', 'stats_enabled', 'registration_date'] list_filter = ['api_enabled', 'stats_enabled', 'registration_date'] search_fields = ['name', 'client_hostname', 'comment'] @@ -944,4 +947,8 @@ class XrayServerV2Admin(admin.ModelAdmin): except Exception as e: return f"Error fetching statistics: {str(e)}" - traffic_statistics.short_description = 'Traffic Statistics' \ No newline at end of file + traffic_statistics.short_description = 'Traffic Statistics' + + +# Register the admin class +admin.site.register(XrayServerV2, XrayServerV2Admin) \ No newline at end of file diff --git a/vpn/templates/admin/purge_users.html b/vpn/templates/admin/purge_users.html new file mode 100644 index 0000000..c99e768 --- /dev/null +++ b/vpn/templates/admin/purge_users.html @@ -0,0 +1,304 @@ + +{% extends "admin/base_site.html" %} +{% load i18n static %} + +{% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %} + +{% block extrahead %} + {{ block.super }} + +{% endblock %} + +{% block content %} +
+

{{ title }}

+ + +
+ {% if servers_info|length == 1 %} + 🎯 Single Server Operation: You are managing users for server "{{ servers_info.0.server.name }}" + {% elif servers_info|length > 10 %} + 🌐 Bulk Operation: You are managing users for {{ servers_info|length }} servers (all available servers) + {% else %} + πŸ“‹ Multi-Server Operation: You are managing users for {{ servers_info|length }} selected servers + {% endif %} +
+ +
+ ⚠️ WARNING: This operation will permanently delete users directly from the VPN servers. + This action cannot be undone and may affect active VPN connections. +
+ + {% if messages %} + {% for message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} + +
+ {% csrf_token %} + +
+

Select Servers and Purge Mode:

+ +
+ +
+ + + + + +
+
+ +
+ +
+
+ +
+

Select Servers to Purge:

+
+
+ + +
+ + + + + + + + + + + + + + {% for server_info in servers_info %} + + + + + + + + + {% endfor %} + +
SelectServer NameTypeStatusUsers on ServerDetails
+ {% if server_info.status == 'online' %} + + {% else %} + ❌ + {% endif %} + + {{ server_info.server.name }} + + {{ server_info.server.server_type }} + + {% if server_info.status == 'online' %} + βœ… Online + {% else %} + ❌ Error + {% endif %} + + {% if server_info.status == 'online' %} + {{ server_info.user_count }} + {% else %} + N/A + {% endif %} + + {% if server_info.status == 'online' %} + {% if server_info.user_count > 0 %} +
+ View users ({{ server_info.user_count }}) +
+ {% for user in server_info.users %} +
+ {{ user.name }} (ID: {{ user.key_id }}) +
Pass: {{ user.password|slice:":8" }}... +
+ {% endfor %} +
+
+ {% else %} + No users + {% endif %} + {% else %} + {{ server_info.error }} + {% endif %} +
+
+
+ +
+ + Cancel +
+
+
+ + +{% endblock %} diff --git a/vpn/templates/admin/vpn/certificate/change_form.html b/vpn/templates/admin/vpn/certificate/change_form.html new file mode 100644 index 0000000..5da9a6f --- /dev/null +++ b/vpn/templates/admin/vpn/certificate/change_form.html @@ -0,0 +1,18 @@ +{% extends "admin/change_form.html" %} + +{% block content %} + {% if show_tab_navigation %} + + {% endif %} + + {{ block.super }} +{% endblock %} \ No newline at end of file diff --git a/vpn/templates/admin/vpn/certificate/change_list.html b/vpn/templates/admin/vpn/certificate/change_list.html new file mode 100644 index 0000000..3dcf650 --- /dev/null +++ b/vpn/templates/admin/vpn/certificate/change_list.html @@ -0,0 +1,18 @@ +{% extends "admin/change_list.html" %} + +{% block content %} + {% if show_tab_navigation %} + + {% endif %} + + {{ block.super }} +{% endblock %} \ No newline at end of file diff --git a/vpn/templates/admin/vpn/inbound/change_form.html b/vpn/templates/admin/vpn/inbound/change_form.html new file mode 100644 index 0000000..5da9a6f --- /dev/null +++ b/vpn/templates/admin/vpn/inbound/change_form.html @@ -0,0 +1,18 @@ +{% extends "admin/change_form.html" %} + +{% block content %} + {% if show_tab_navigation %} + + {% endif %} + + {{ block.super }} +{% endblock %} \ No newline at end of file diff --git a/vpn/templates/admin/vpn/inbound/change_list.html b/vpn/templates/admin/vpn/inbound/change_list.html new file mode 100644 index 0000000..3dcf650 --- /dev/null +++ b/vpn/templates/admin/vpn/inbound/change_list.html @@ -0,0 +1,18 @@ +{% extends "admin/change_list.html" %} + +{% block content %} + {% if show_tab_navigation %} + + {% endif %} + + {{ block.super }} +{% endblock %} \ No newline at end of file diff --git a/vpn/templates/admin/vpn/server/change_form.html b/vpn/templates/admin/vpn/server/change_form.html new file mode 100644 index 0000000..11f42ab --- /dev/null +++ b/vpn/templates/admin/vpn/server/change_form.html @@ -0,0 +1,39 @@ + +{% extends "admin/change_form.html" %} +{% load static %} + +{% block extrahead %} + {{ block.super }} + +{% endblock %} + +{% block submit_buttons_bottom %} + {{ block.super }} + + {% if original %} +
+
+
+
+

+ Server Actions +

+
+ +
+
+
+ {% endif %} +{% endblock %} diff --git a/vpn/templates/admin/vpn/server/change_list.html b/vpn/templates/admin/vpn/server/change_list.html new file mode 100644 index 0000000..50546e2 --- /dev/null +++ b/vpn/templates/admin/vpn/server/change_list.html @@ -0,0 +1,166 @@ + +{% extends "admin/change_list.html" %} + +{% block content_title %} +

{{ title }}

+ + +
+

πŸš€ Bulk Server Operations

+

+ Perform operations on all available servers at once. These actions will include all servers in the system. +

+ + + +
+ + πŸ’‘ Tip: You can also select specific servers below and use the "Actions" dropdown, + or click individual action buttons in the "Actions" column for single-server operations. + +
+
+{% endblock %} + +{% block extrahead %} + {{ block.super }} + {% load static %} + + + + +{% endblock %} + +{% block result_list %} + +
+
+
+ πŸ“Š Server Overview: +
+
+ Total Servers: + {{ cl.result_count }} +
+ {% if cl.result_count > 0 %} +
+ Available Operations: + Move Links, Purge Users, Bulk Actions +
+ {% endif %} +
+
+ + {{ block.super }} +{% endblock %} diff --git a/vpn/templates/admin/vpn/subscriptiongroup/change_form.html b/vpn/templates/admin/vpn/subscriptiongroup/change_form.html index ff202ac..5da9a6f 100644 --- a/vpn/templates/admin/vpn/subscriptiongroup/change_form.html +++ b/vpn/templates/admin/vpn/subscriptiongroup/change_form.html @@ -4,14 +4,12 @@ {% if show_tab_navigation %} {% endif %} diff --git a/vpn/templates/admin/vpn/subscriptiongroup/change_list.html b/vpn/templates/admin/vpn/subscriptiongroup/change_list.html index 8c0ab41..3dcf650 100644 --- a/vpn/templates/admin/vpn/subscriptiongroup/change_list.html +++ b/vpn/templates/admin/vpn/subscriptiongroup/change_list.html @@ -4,14 +4,12 @@ {% if show_tab_navigation %} {% endif %} diff --git a/vpn/templates/admin/vpn/usersubscription/change_form.html b/vpn/templates/admin/vpn/usersubscription/change_form.html index b4d6a8f..5da9a6f 100644 --- a/vpn/templates/admin/vpn/usersubscription/change_form.html +++ b/vpn/templates/admin/vpn/usersubscription/change_form.html @@ -1,18 +1,18 @@ {% extends "admin/change_form.html" %} {% block content %} + {% if show_tab_navigation %} + {% endif %} {{ block.super }} {% endblock %} \ No newline at end of file diff --git a/vpn/templates/admin/vpn/usersubscription/change_list.html b/vpn/templates/admin/vpn/usersubscription/change_list.html index e1fe901..3dcf650 100644 --- a/vpn/templates/admin/vpn/usersubscription/change_list.html +++ b/vpn/templates/admin/vpn/usersubscription/change_list.html @@ -1,18 +1,18 @@ {% extends "admin/change_list.html" %} {% block content %} + {% if show_tab_navigation %} + {% endif %} {{ block.super }} {% endblock %} \ No newline at end of file diff --git a/vpn/utils.py b/vpn/utils.py new file mode 100644 index 0000000..a4054b1 --- /dev/null +++ b/vpn/utils.py @@ -0,0 +1,21 @@ +""" +Utility functions for VPN application +""" +import json +from django.utils.safestring import mark_safe + + +def format_object(data): + """ + Format various data types for display in Django admin interface + """ + try: + if isinstance(data, dict): + formatted_data = json.dumps(data, indent=2) + return mark_safe(f"
{formatted_data}
") + elif isinstance(data, str): + return mark_safe(f"
{data}
") + else: + return mark_safe(f"
{str(data)}
") + except Exception as e: + return mark_safe(f"Error: {e}") \ No newline at end of file