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'βͺ Check Status'
- 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_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 += '
'
- html += f'
'
- html += f'
'
- html += f'{link.link[:16]}...' if len(link.link) > 16 else link.link
- html += f'
'
- if link.comment:
- html += f'
{link.comment}
'
- html += f'
'
-
- # Link stats and actions
- html += f'
'
- if link_stats:
- html += f'
'
- html += f'β¨ {link_stats.total_connections}'
- html += f' '
-
- # Test link button
- html += f'
π '
-
- # Delete button
- html += f'
ποΈ '
-
- # Last access
- if link.last_access_time:
- local_time = localtime(link.last_access_time)
- html += f'
'
- html += f'{local_time.strftime("%m-%d %H:%M")}'
- html += f' '
- else:
- html += f'
'
- html += f'Never'
- html += f' '
-
- html += f'
'
-
- # Add link button
- html += f'
'
- html += f''
- html += f'β Add Link'
- 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 += f'{type_icon} {server.name} ({type_label})'
- 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''
+ )
+
+
+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'βͺ Check Status'
+ 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_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 += '
'
+ html += f'
'
+ html += f'
'
+ html += f'{link.link[:16]}...' if len(link.link) > 16 else link.link
+ html += f'
'
+ if link.comment:
+ html += f'
{link.comment}
'
+ html += f'
'
+
+ # Link stats and actions
+ html += f'
'
+ if link_stats:
+ html += f'
'
+ html += f'β¨ {link_stats.total_connections}'
+ html += f' '
+
+ # Test link button
+ html += f'
π '
+
+ # Delete button
+ html += f'
ποΈ '
+
+ # Last access
+ if link.last_access_time:
+ local_time = localtime(link.last_access_time)
+ html += f'
'
+ html += f'{local_time.strftime("%m-%d %H:%M")}'
+ html += f' '
+ else:
+ html += f'
'
+ html += f'Never'
+ html += f' '
+
+ html += f'
'
+
+ # Add link button
+ html += f'
'
+ html += f''
+ html += f'β Add Link'
+ 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 += f'{type_icon} {server.name} ({type_label})'
+ 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 %}
+
+
+
+
+
+{% 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 %}
+
+ {% 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