Added TG bot

This commit is contained in:
Ultradesu
2025-08-15 04:02:22 +03:00
parent 402e4d84fc
commit 36f9e495b5
52 changed files with 6376 additions and 2081 deletions

View File

@@ -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 = [

View File

@@ -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

239
static/admin/css/main.css Normal file
View File

@@ -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;
}
}

0
telegram_bot/__init__.py Normal file
View File

621
telegram_bot/admin.py Normal file
View File

@@ -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 = '<span style="color: green; font-weight: bold;">🟢 Bot is RUNNING</span>'
else:
status_html = '<span style="color: red; font-weight: bold;">🔴 Bot is STOPPED</span>'
# Add control buttons
status_html += '<br><br>'
if is_running:
status_html += f'<a class="button" href="{reverse("admin:telegram_bot_stop_bot")}">Stop Bot</a> '
status_html += f'<a class="button" href="{reverse("admin:telegram_bot_restart_bot")}">Restart Bot</a>'
else:
if obj.enabled and obj.bot_token:
status_html += f'<a class="button" href="{reverse("admin:telegram_bot_start_bot")}">Start Bot</a>'
else:
status_html += '<span style="color: gray;">Configure bot token and enable bot to start</span>'
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('<span style="color: blue;">⬇️ Incoming</span>')
else:
return format_html('<span style="color: green;">⬆️ Outgoing</span>')
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('<pre style="max-width: 800px; overflow: auto;">{}</pre>', 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('<span style="color: green; font-weight: bold;">✅ Approved</span>')
else:
return format_html('<span style="color: orange; font-weight: bold;">🔄 Pending</span>')
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('<span style="color: gray; font-style: italic;">{}</span>', 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)

75
telegram_bot/apps.py Normal file
View File

@@ -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}")

1060
telegram_bot/bot.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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)

View File

View File

@@ -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

View File

@@ -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}")
)

View File

@@ -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')],
},
),
]

View File

@@ -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'),
),
]

View File

@@ -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')],
},
),
]

View File

@@ -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'),
),
]

View File

@@ -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'),
),
]

View File

@@ -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),
),
]

View File

@@ -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),
),
]

View File

292
telegram_bot/models.py Normal file
View File

@@ -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

3
telegram_bot/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

3
telegram_bot/views.py Normal file
View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

View File

File diff suppressed because it is too large Load Diff

45
vpn/admin/__init__.py Normal file
View File

@@ -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'
]

485
vpn/admin/access.py Normal file
View File

@@ -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(
'<button type="button" class="generate-link" onclick="generateLink(this)">🔄</button>'
)
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(
'<div style="background: #fff3cd; border: 1px solid #ffeaa7; padding: 8px; border-radius: 4px;">' +
'<strong> User Statistics:</strong><br>' +
'No cached statistics available.<br>' +
'<small>Run "Update user statistics cache" action to populate data.</small>' +
'</div>'
)
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"<span style='color: red;'>Cache error: {e}</span>")
@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(
'<div style="font-size: 12px; margin-bottom: 8px;">'
'<strong>🔗 {} link(s)</strong>'
'</div>'
'<a href="{}" target="_blank" style="background: #4ade80; color: #000; padding: 4px 8px; border-radius: 4px; text-decoration: none; font-size: 11px; font-weight: bold;">🌐 User Portal</a>',
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(
'<a href="{}" target="_blank" style="color: #2563eb; text-decoration: none; font-family: monospace;">{}</a>',
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'<div style="font-size: 12px;">'
f'<span style="color: {color}; font-weight: bold;">✨ {stats.total_connections} total</span><br>'
f'<span style="color: #6b7280;">📅 {stats.recent_connections} last 30d</span>'
f'</div>'
)
except:
return mark_safe('<span style="color: #dc2626; font-size: 12px;">No cache</span>')
@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('<span style="color: #9ca3af; font-size: 11px;">No data</span>')
# Create wider mini chart for better visibility
max_val = max(stats.daily_usage) if stats.daily_usage else 1
chart_html = '<div style="display: flex; align-items: end; gap: 1px; height: 35px; width: 180px;">'
# 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'<div style="background: {color}; width: 5px; height: {height_percent}%; min-height: 1px; border-radius: 1px;" title="{day_count} connections"></div>'
chart_html += '</div>'
# 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'<div style="font-size: 10px; color: #6b7280; margin-top: 2px;">'
chart_html += f'Max: {max_val} | Avg: {avg_daily:.1f}'
chart_html += f'</div>'
return mark_safe(chart_html)
except:
return mark_safe('<span style="color: #dc2626; font-size: 11px;">-</span>')
@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'<span style="color: {color}; font-weight: bold; font-size: 12px;">{formatted_date}</span>'
f'<br><small style="color: {color}; font-size: 10px;">{relative}</small>'
)
return mark_safe('<span style="color: #dc2626; font-weight: bold; font-size: 12px;">Never</span>')
@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']

57
vpn/admin/base.py Normal file
View File

@@ -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('<span style="color: #dc3545;">No hash</span>')
portal_url = f"https://{EXTERNAL_ADDRESS}/u/{hash_value}"
return mark_safe(
f'<div style="display: flex; align-items: center; gap: 10px;">'
f'<code style="background: #f8f9fa; padding: 4px 8px; border-radius: 3px; font-size: 12px;">{hash_value[:12]}...</code>'
f'<a href="{portal_url}" target="_blank" style="color: #007cba; text-decoration: none;">🔗 Portal</a>'
f'</div>'
)
class BaseListFilter(admin.SimpleListFilter):
"""Base filter class with common functionality"""
pass

179
vpn/admin/logs.py Normal file
View File

@@ -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"<pre style='white-space: pre-wrap; max-width: 800px;'>{obj.message}</pre>")
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(
'<span style="font-family: monospace; color: #2563eb;">{}</span>',
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)

826
vpn/admin/server.py Normal file
View File

@@ -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('<int:server_id>/check-status/', self.admin_site.admin_view(self.check_server_status_view), name='server_check_status'),
path('<int:object_id>/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'<span title="{obj.comment}" style="font-size: 12px;">{short_comment}</span>')
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'<div style="color: #6b7280; font-size: 11px;">↑{format_bytes(total_uplink)}{format_bytes(total_downlink)}</div>'
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'<div style="font-size: 12px;">' +
f'<div style="color: {color}; font-weight: bold;">👥 {user_count} users</div>' +
f'<div style="color: #6b7280;">📡 {total_links} inbounds</div>' +
traffic_info +
f'</div>'
)
else:
return mark_safe(
f'<div style="font-size: 12px;">' +
f'<div style="color: {color}; font-weight: bold;">👥 {user_count} users</div>' +
f'<div style="color: #6b7280;">🔗 {active_links}/{total_links} active</div>' +
f'</div>'
)
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'<span style="color: #dc2626; font-size: 11px;">Stats error: {e}</span>')
@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'<div style="font-size: 11px; color: #6b7280;">' +
f'<div>📊 Activity data</div>' +
f'<div><small>Click to view details</small></div>' +
f'</div>'
)
except Exception as e:
return mark_safe(f'<span style="color: #dc2626; font-size: 11px;">Activity unavailable</span>')
@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'<div style="color: #6b7280; font-size: 11px;">' +
f'{icon} {obj.server_type.title()}<br>' +
f'<button type="button" class="check-status-btn btn btn-xs" '
f'data-server-id="{obj.id}" data-server-name="{obj.name}" '
f'data-server-type="{obj.server_type}" '
f'style="background: #007cba; color: white; border: none; padding: 2px 6px; '
f'border-radius: 3px; font-size: 10px; cursor: pointer;">'
f'⚪ Check Status'
f'</button>' +
f'</div>'
)
except Exception as e:
return mark_safe(
f'<div style="color: #f97316; font-size: 11px; font-weight: bold;">' +
f'⚠️ Error<br>' +
f'<span style="font-weight: normal;" title="{str(e)}">' +
f'{str(e)[:25]}...</span>' +
f'</div>'
)
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(
'<span style="font-weight: bold;">{}</span> link(s)',
count
)

604
vpn/admin/user.py Normal file
View File

@@ -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 = '<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; margin: 10px 0;">'
# Legacy VPN section
html += '<div style="margin-bottom: 15px;">'
html += '<h4 style="margin: 0 0 10px 0; color: #495057;">📡 Legacy VPN (Outline/Wireguard)</h4>'
if acl_count > 0:
html += f'<p style="margin: 5px 0;">✅ Access to <strong>{acl_count}</strong> server(s)</p>'
html += f'<p style="margin: 5px 0;">🔗 Total links: <strong>{legacy_links}</strong></p>'
else:
html += '<p style="margin: 5px 0; color: #6c757d;">No legacy VPN access</p>'
html += '</div>'
# Xray section
html += '<div>'
html += '<h4 style="margin: 0 0 10px 0; color: #495057;">🚀 Xray VPN</h4>'
if xray_groups:
html += f'<p style="margin: 5px 0;">✅ Active subscriptions: <strong>{len(xray_groups)}</strong></p>'
html += '<ul style="margin: 5px 0; padding-left: 20px;">'
for group in xray_groups:
html += f'<li>{group}</li>'
html += '</ul>'
# 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'<p style="margin: 10px 0 5px 0; color: #007cba;"><strong>📊 Traffic Statistics:</strong></p>'
html += f'<p style="margin: 5px 0 5px 20px;">↑ Upload: <strong>{format_bytes(traffic_total_up)}</strong></p>'
html += f'<p style="margin: 5px 0 5px 20px;">↓ Download: <strong>{format_bytes(traffic_total_down)}</strong></p>'
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 += '<p style="margin: 5px 0; color: #6c757d;">No Xray subscriptions</p>'
html += '</div>'
html += '</div>'
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(
'<div style="display: flex; gap: 10px; flex-wrap: wrap;">' +
'<a href="{}" target="_blank" style="background: #4ade80; color: #000; padding: 4px 8px; border-radius: 4px; text-decoration: none; font-size: 12px; font-weight: bold;">🌐 Portal</a>' +
'<a href="{}" target="_blank" style="background: #3b82f6; color: #fff; padding: 4px 8px; border-radius: 4px; text-decoration: none; font-size: 12px; font-weight: bold;">📄 JSON</a>' +
'</div>',
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 = '<div class="user-management-section">'
# Overall Statistics
html += '<div style="background: #e7f3ff; border-left: 4px solid #007cba; padding: 12px; margin-bottom: 16px; border-radius: 4px;">'
html += f'<div style="display: flex; gap: 20px; margin-bottom: 8px; flex-wrap: wrap;">'
html += f'<div><strong>Total Uses:</strong> {user_stats["total_connections"] or 0}</div>'
html += f'<div><strong>Recent (30d):</strong> {user_stats["recent_connections"] or 0}</div>'
html += f'<div><strong>Total Links:</strong> {user_stats["total_links"] or 0}</div>'
if user_stats["max_daily_peak"]:
html += f'<div><strong>Daily Peak:</strong> {user_stats["max_daily_peak"]}</div>'
html += f'</div>'
html += '</div>'
# Server Management
if user_acls:
html += '<h4 style="color: #007cba; margin: 16px 0 8px 0;">🔗 Server Access & Links</h4>'
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'<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">'
html += f'<h5 style="margin: 0; color: #333; font-size: 14px; font-weight: 600;">{type_icon} {server.name} ({type_label})</h5>'
# Server stats
server_stat = next((s for s in server_breakdown if s['server_name'] == server.name), None)
if server_stat:
html += f'<span style="background: #f8f9fa; padding: 4px 8px; border-radius: 4px; font-size: 12px; color: #6c757d;">'
html += f'📊 {server_stat["connections"]} uses ({server_stat["links"]} links)'
html += f'</span>'
html += f'</div>'
html += '<div class="server-section">'
# 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 += '<div class="link-item">'
html += f'<div style="flex: 1;">'
html += f'<div style="font-family: monospace; font-size: 13px; color: #007cba; font-weight: 500;">'
html += f'{link.link[:16]}...' if len(link.link) > 16 else link.link
html += f'</div>'
if link.comment:
html += f'<div style="font-size: 11px; color: #6c757d;">{link.comment}</div>'
html += f'</div>'
# Link stats and actions
html += f'<div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap;">'
if link_stats:
html += f'<span style="background: #d4edda; color: #155724; padding: 2px 6px; border-radius: 3px; font-size: 10px;">'
html += f'{link_stats.total_connections}'
html += f'</span>'
# Test link button
html += f'<a href="{EXTERNAL_ADDRESS}/ss/{link.link}#{server.name}" target="_blank" '
html += f'class="btn btn-sm btn-primary btn-sm-custom">🔗</a>'
# Delete button
html += f'<button type="button" class="btn btn-sm btn-danger btn-sm-custom delete-link-btn" '
html += f'data-link-id="{link.id}" data-link-name="{link.link[:12]}">🗑️</button>'
# Last access
if link.last_access_time:
local_time = localtime(link.last_access_time)
html += f'<span style="background: #f8f9fa; padding: 2px 6px; border-radius: 3px; font-size: 10px; color: #6c757d;">'
html += f'{local_time.strftime("%m-%d %H:%M")}'
html += f'</span>'
else:
html += f'<span style="background: #f8d7da; color: #721c24; padding: 2px 6px; border-radius: 3px; font-size: 10px;">'
html += f'Never'
html += f'</span>'
html += f'</div></div>'
# Add link button
html += f'<div style="text-align: center; margin-top: 12px;">'
html += f'<button type="button" class="btn btn-sm btn-success add-link-btn" '
html += f'data-server-id="{server.id}" data-server-name="{server.name}">'
html += f' Add Link'
html += f'</button>'
html += f'</div>'
html += '</div>' # End server-section
# Add server access section
if unassigned_servers:
html += '<div style="background: #d1ecf1; border-left: 4px solid #bee5eb; padding: 12px; margin-top: 16px; border-radius: 4px;">'
html += '<h5 style="margin: 0 0 8px 0; color: #0c5460; font-size: 13px;"> Available Servers</h5>'
html += '<div style="display: flex; gap: 8px; flex-wrap: wrap;">'
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'<button type="button" class="btn btn-sm btn-outline-info btn-sm-custom add-server-btn" '
html += f'data-server-id="{server.id}" data-server-name="{server.name}" '
html += f'title="{type_label} server">'
html += f'{type_icon} {server.name} ({type_label})'
html += f'</button>'
html += '</div></div>'
html += '</div>' # End user-management-section
return mark_safe(html)
except Exception as e:
return mark_safe(f'<span style="color: #dc3545;">Error loading management interface: {e}</span>')
@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('<div style="color: #6c757d; font-style: italic; padding: 12px; background: #f8f9fa; border-radius: 4px;">No recent activity (last 7 days)</div>')
html = '<div style="background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 4px; padding: 0; max-height: 300px; overflow-y: auto;">'
# Header
html += '<div style="background: #e9ecef; padding: 8px 12px; border-bottom: 1px solid #dee2e6; font-weight: 600; font-size: 12px; color: #495057;">'
html += f'📊 Recent Activity ({recent_logs.count()} entries, last 7 days)'
html += '</div>'
# 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'<div style="display: flex; justify-content: space-between; align-items: center; padding: 6px 12px; border-bottom: 1px solid #f1f3f4; background: {bg_color};">'
# Left side - server and link info
html += f'<div style="display: flex; gap: 8px; align-items: center; flex: 1; min-width: 0;">'
html += f'<span style="color: {status_color}; font-size: 14px;">{icon}</span>'
html += f'<div style="overflow: hidden;">'
html += f'<div style="font-weight: 500; font-size: 12px; color: #495057; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">{log.server}</div>'
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'<div style="font-family: monospace; font-size: 10px; color: #6c757d;">{link_short}</div>'
html += f'</div></div>'
# Right side - timestamp and status
html += f'<div style="text-align: right; flex-shrink: 0;">'
html += f'<div style="font-size: 10px; color: #6c757d;">{local_time.strftime("%m-%d %H:%M")}</div>'
html += f'<div style="font-size: 9px; color: {status_color}; font-weight: 500;">{log.action}</div>'
html += f'</div>'
html += f'</div>'
# 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'<div style="background: #e9ecef; padding: 6px 12px; font-size: 11px; color: #6c757d; text-align: center;">'
html += f'Showing 15 of {total_recent} entries from last 7 days'
html += f'</div>'
html += '</div>'
return mark_safe(html)
except Exception as e:
return mark_safe(f'<span style="color: #dc3545; font-size: 12px;">Error loading activity: {e}</span>')
@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'<div style="background: #fff3cd; padding: 10px; border-radius: 5px; border-left: 4px solid #ffc107;">'
f'<span style="color: #856404;">🔗 Ready to link: @{obj.telegram_username}</span><br/>'
f'<small>User will be automatically linked when they message the bot</small></div>')
else:
return mark_safe('<span style="color: #6c757d;">No Telegram account linked</span>')
html = '<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; margin: 10px 0;">'
html += '<h4 style="margin: 0 0 10px 0; color: #495057;">📱 Telegram Account Information</h4>'
# Telegram User ID
html += f'<p style="margin: 5px 0;"><strong>User ID:</strong> <code>{obj.telegram_user_id}</code></p>'
# Telegram Username
if obj.telegram_username:
html += f'<p style="margin: 5px 0;"><strong>Username:</strong> @{obj.telegram_username}</p>'
# 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'<p style="margin: 5px 0;"><strong>Name:</strong> {full_name}</p>'
# Telegram Phone (if available)
if obj.telegram_phone:
html += f'<p style="margin: 5px 0;"><strong>Phone:</strong> {obj.telegram_phone}</p>'
# 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'<p style="margin: 10px 0 5px 0; color: #007cba;"><strong>📝 Access Requests:</strong> {requests_count}</p>'
# 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'<p style="margin: 5px 0 5px 20px;">Latest: <span style="color: {status_color}; font-weight: bold;">{status_text}</span></p>'
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'<div style="margin-top: 15px; padding-top: 10px; border-top: 1px solid #dee2e6;">'
html += f'<a href="{unlink_url}" class="button" style="background: #dc3545; color: white; text-decoration: none; padding: 8px 15px; border-radius: 3px; font-size: 13px;" onclick="return confirm(\'Are you sure you want to unlink this Telegram account?\')">🔗💥 Unlink Telegram Account</a>'
html += '</div>'
html += '</div>'
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('<int:user_id>/add-link/', self.admin_site.admin_view(self.add_link_view), name='user_add_link'),
path('<int:user_id>/delete-link/<int:link_id>/', self.admin_site.admin_view(self.delete_link_view), name='user_delete_link'),
path('<int:user_id>/add-server-access/', self.admin_site.admin_view(self.add_server_access_view), name='user_add_server_access'),
path('<int:user_id>/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)

73
vpn/admin_minimal.py Normal file
View File

@@ -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}")

16
vpn/admin_test.py Normal file
View File

@@ -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!")

View File

@@ -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)
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

View File

@@ -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")

View File

@@ -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),
),
]

View File

@@ -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'},
),
]

View File

@@ -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)

View File

@@ -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):

View File

@@ -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'
traffic_statistics.short_description = 'Traffic Statistics'
# Register the admin class
admin.site.register(XrayServerV2, XrayServerV2Admin)

View File

@@ -0,0 +1,304 @@
<!-- vpn/templates/admin/purge_users.html -->
{% extends "admin/base_site.html" %}
{% load i18n static %}
{% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}
{% block extrahead %}
{{ block.super }}
<link rel="stylesheet" type="text/css" href="{% static 'admin/css/server_actions.css' %}">
{% endblock %}
{% block content %}
<div class="content-main">
<h1>{{ title }}</h1>
<!-- Context Information -->
<div class="alert alert-info" style="margin: 10px 0; padding: 10px; border-radius: 4px; background-color: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460;">
{% if servers_info|length == 1 %}
<strong>🎯 Single Server Operation:</strong> You are managing users for server "{{ servers_info.0.server.name }}"
{% elif servers_info|length > 10 %}
<strong>🌐 Bulk Operation:</strong> You are managing users for {{ servers_info|length }} servers (all available servers)
{% else %}
<strong>📋 Multi-Server Operation:</strong> You are managing users for {{ servers_info|length }} selected servers
{% endif %}
</div>
<div class="alert alert-warning" style="margin: 10px 0; padding: 15px; border-radius: 4px; background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404;">
<strong>⚠️ WARNING:</strong> This operation will permanently delete users directly from the VPN servers.
This action cannot be undone and may affect active VPN connections.
</div>
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }}" style="margin: 10px 0; padding: 10px; border-radius: 4px;
{% if message.tags == 'error' %}background-color: #f8d7da; border: 1px solid #f5c6cb; color: #721c24;
{% elif message.tags == 'success' %}background-color: #d4edda; border: 1px solid #c3e6cb; color: #155724;
{% elif message.tags == 'warning' %}background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404;
{% elif message.tags == 'info' %}background-color: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460;
{% endif %}">
{{ message }}
</div>
{% endfor %}
{% endif %}
<form method="post" id="purge-form">
{% csrf_token %}
<div class="form-row" style="margin-bottom: 20px;">
<h3>Select Servers and Purge Mode:</h3>
<div style="margin-bottom: 15px;">
<label for="purge_mode"><strong>Purge Mode:</strong></label>
<div style="margin-top: 5px;">
<input type="radio" id="purge_unmanaged" name="purge_mode" value="unmanaged" checked onchange="updatePurgeDescription()">
<label for="purge_unmanaged" style="font-weight: normal; margin-left: 5px; margin-right: 20px;">
<span style="color: #28a745;">Safe Purge</span> - Only unmanaged users
</label>
<input type="radio" id="purge_all" name="purge_mode" value="all" onchange="updatePurgeDescription()">
<label for="purge_all" style="font-weight: normal; margin-left: 5px; color: #dc3545;">
<span style="color: #dc3545;">⚠️ Full Purge</span> - ALL users (including OutFleet managed)
</label>
</div>
</div>
<div id="purge-description" style="padding: 10px; border-radius: 5px; margin-bottom: 15px;">
<!-- Description will be updated by JavaScript -->
</div>
</div>
<div class="form-row" style="margin-bottom: 20px;">
<h3>Select Servers to Purge:</h3>
<div style="max-height: 400px; overflow-y: auto; border: 1px solid #ddd; padding: 10px;">
<div style="margin-bottom: 10px;">
<button type="button" onclick="toggleAllServers()" style="padding: 5px 10px; margin-right: 10px;">Select All</button>
<button type="button" onclick="toggleAllServers(false)" style="padding: 5px 10px;">Deselect All</button>
</div>
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background-color: #f5f5f5;">
<th style="padding: 8px; border: 1px solid #ddd; width: 50px;">Select</th>
<th style="padding: 8px; border: 1px solid #ddd;">Server Name</th>
<th style="padding: 8px; border: 1px solid #ddd;">Type</th>
<th style="padding: 8px; border: 1px solid #ddd;">Status</th>
<th style="padding: 8px; border: 1px solid #ddd;">Users on Server</th>
<th style="padding: 8px; border: 1px solid #ddd;">Details</th>
</tr>
</thead>
<tbody>
{% for server_info in servers_info %}
<tr class="server-row" data-server-id="{{ server_info.server.id }}">
<td style="padding: 8px; border: 1px solid #ddd; text-align: center;">
{% if server_info.status == 'online' %}
<input type="checkbox" name="selected_servers" value="{{ server_info.server.id }}"
class="server-checkbox" onchange="updateSubmitButton()">
{% else %}
<span style="color: #ccc;" title="Server unavailable"></span>
{% endif %}
</td>
<td style="padding: 8px; border: 1px solid #ddd;">
<strong>{{ server_info.server.name }}</strong>
</td>
<td style="padding: 8px; border: 1px solid #ddd;">
{{ server_info.server.server_type }}
</td>
<td style="padding: 8px; border: 1px solid #ddd;">
{% if server_info.status == 'online' %}
<span style="color: #28a745;">✅ Online</span>
{% else %}
<span style="color: #dc3545;">❌ Error</span>
{% endif %}
</td>
<td style="padding: 8px; border: 1px solid #ddd; text-align: center;">
{% if server_info.status == 'online' %}
<strong>{{ server_info.user_count }}</strong>
{% else %}
<span style="color: #ccc;">N/A</span>
{% endif %}
</td>
<td style="padding: 8px; border: 1px solid #ddd; font-size: 12px;">
{% if server_info.status == 'online' %}
{% if server_info.user_count > 0 %}
<details>
<summary style="cursor: pointer;">View users ({{ server_info.user_count }})</summary>
<div style="margin-top: 5px; max-height: 150px; overflow-y: auto; background-color: #f8f9fa; padding: 5px; border-radius: 3px;">
{% for user in server_info.users %}
<div style="margin: 2px 0; font-family: monospace; font-size: 11px;">
<strong>{{ user.name }}</strong> (ID: {{ user.key_id }})
<br><span style="color: #666;">Pass: {{ user.password|slice:":8" }}...</span>
</div>
{% endfor %}
</div>
</details>
{% else %}
<span style="color: #666;">No users</span>
{% endif %}
{% else %}
<span style="color: #dc3545;">{{ server_info.error }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="submit-row">
<input type="submit" value="🗑️ Purge Selected Servers" class="default" id="submit-btn" disabled
style="background-color: #dc3545; border-color: #dc3545;">
<a href="{% url 'admin:vpn_server_changelist' %}" class="button cancel">Cancel</a>
</div>
</form>
</div>
<script>
function updatePurgeDescription() {
var purgeMode = document.querySelector('input[name="purge_mode"]:checked').value;
var descriptionDiv = document.getElementById('purge-description');
if (purgeMode === 'unmanaged') {
descriptionDiv.innerHTML = `
<div style="background-color: #d4edda; border-left: 4px solid #28a745; color: #155724;">
<h4 style="margin: 0 0 5px 0;">Safe Purge Mode</h4>
<p style="margin: 0;">
• Only removes users that are <strong>NOT</strong> managed by OutFleet<br>
• Preserves all users that exist in the OutFleet database<br>
• Safe to use - will not affect your managed users<br>
• Recommended for cleaning up orphaned or manually created users
</p>
</div>
`;
} else {
descriptionDiv.innerHTML = `
<div style="background-color: #f8d7da; border-left: 4px solid #dc3545; color: #721c24;">
<h4 style="margin: 0 0 5px 0;">⚠️ DANGEROUS: Full Purge Mode</h4>
<p style="margin: 0;">
• <strong>REMOVES ALL USERS</strong> from the server, including OutFleet managed users<br>
• <strong>WILL DISCONNECT ALL ACTIVE VPN SESSIONS</strong><br>
• OutFleet managed users will be recreated during next sync<br>
• Use only if you want to completely reset the server<br>
• <strong>THIS ACTION CANNOT BE UNDONE</strong>
</p>
</div>
`;
}
}
function toggleAllServers(selectAll = true) {
var checkboxes = document.getElementsByClassName('server-checkbox');
for (var i = 0; i < checkboxes.length; i++) {
checkboxes[i].checked = selectAll;
}
updateSubmitButton();
}
function updateSubmitButton() {
var checkboxes = document.getElementsByClassName('server-checkbox');
var submitBtn = document.getElementById('submit-btn');
var hasSelected = false;
for (var i = 0; i < checkboxes.length; i++) {
if (checkboxes[i].checked) {
hasSelected = true;
break;
}
}
submitBtn.disabled = !hasSelected;
// Update button text based on purge mode
var purgeMode = document.querySelector('input[name="purge_mode"]:checked').value;
if (purgeMode === 'all') {
submitBtn.innerHTML = '<i class="fas fa-exclamation-triangle mr-2"></i>PURGE ALL USERS';
submitBtn.className = 'btn btn-danger';
} else {
submitBtn.innerHTML = '<i class="fas fa-trash mr-2"></i>Purge Unmanaged Users';
submitBtn.className = 'btn btn-warning';
}
}
// Form submission confirmation
document.getElementById('purge-form').addEventListener('submit', function(e) {
var checkboxes = document.getElementsByClassName('server-checkbox');
var selectedCount = 0;
var selectedServers = [];
for (var i = 0; i < checkboxes.length; i++) {
if (checkboxes[i].checked) {
selectedCount++;
var row = checkboxes[i].closest('tr');
var serverName = row.querySelector('td:nth-child(2) strong').textContent;
selectedServers.push(serverName);
}
}
var purgeMode = document.querySelector('input[name="purge_mode"]:checked').value;
var totalUsers = 0;
// Calculate total users that will be affected
for (var i = 0; i < checkboxes.length; i++) {
if (checkboxes[i].checked) {
var row = checkboxes[i].closest('tr');
var userCountBadge = row.querySelector('td:nth-child(5) .badge');
if (userCountBadge) {
totalUsers += parseInt(userCountBadge.textContent) || 0;
}
}
}
var confirmMessage = '';
if (purgeMode === 'all') {
confirmMessage = `⚠️ DANGER: FULL PURGE CONFIRMATION ⚠️\n\n`;
confirmMessage += `You are about to PERMANENTLY DELETE ALL USERS from ${selectedCount} server(s):\n`;
confirmMessage += `${selectedServers.join(', ')}\n\n`;
confirmMessage += `This will:\n`;
confirmMessage += `• DELETE ALL ${totalUsers} users from selected servers\n`;
confirmMessage += `• DISCONNECT ALL ACTIVE VPN SESSIONS\n`;
confirmMessage += `• REMOVE BOTH managed and unmanaged users\n`;
confirmMessage += `• Cannot be undone\n\n`;
confirmMessage += `OutFleet managed users will be recreated during next sync.\n\n`;
confirmMessage += `Type "PURGE ALL" to confirm this dangerous operation:`;
var userInput = prompt(confirmMessage);
if (userInput !== "PURGE ALL") {
e.preventDefault();
alert("Operation cancelled. You must type exactly 'PURGE ALL' to confirm.");
return;
}
} else {
confirmMessage = `Safe Purge Confirmation\n\n`;
confirmMessage += `You are about to purge unmanaged users from ${selectedCount} server(s):\n`;
confirmMessage += `${selectedServers.join(', ')}\n\n`;
confirmMessage += `This will:\n`;
confirmMessage += `• Remove only users NOT managed by OutFleet\n`;
confirmMessage += `• Preserve all OutFleet managed users\n`;
confirmMessage += `• Help clean up orphaned users\n\n`;
confirmMessage += `Are you sure you want to continue?`;
if (!confirm(confirmMessage)) {
e.preventDefault();
}
}
});
// Initialize page
document.addEventListener('DOMContentLoaded', function() {
updatePurgeDescription();
updateSubmitButton();
// Add event listeners to purge mode radio buttons
document.querySelectorAll('input[name="purge_mode"]').forEach(function(radio) {
radio.addEventListener('change', function() {
updatePurgeDescription();
updateSubmitButton();
});
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,18 @@
{% extends "admin/change_form.html" %}
{% block content %}
{% if show_tab_navigation %}
<div class="module" style="margin-bottom: 20px;">
<div style="display: flex; border-bottom: 1px solid #ddd;">
{% for tab in tabs %}
<a href="{{ tab.url }}"
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid {% if tab.active %}#417690{% else %}transparent{% endif %}; color: {% if tab.active %}#417690{% else %}#666{% endif %};">
{% if tab.name == 'subscription_groups' %}📋{% elif tab.name == 'user_subscriptions' %}👥{% elif tab.name == 'certificates' %}🔒{% elif tab.name == 'inbound_templates' %}⚙️{% endif %} {{ tab.label }}
</a>
{% endfor %}
</div>
</div>
{% endif %}
{{ block.super }}
{% endblock %}

View File

@@ -0,0 +1,18 @@
{% extends "admin/change_list.html" %}
{% block content %}
{% if show_tab_navigation %}
<div class="module" style="margin-bottom: 20px;">
<div style="display: flex; border-bottom: 1px solid #ddd;">
{% for tab in tabs %}
<a href="{{ tab.url }}"
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid {% if tab.active %}#417690{% else %}transparent{% endif %}; color: {% if tab.active %}#417690{% else %}#666{% endif %};">
{% if tab.name == 'subscription_groups' %}📋{% elif tab.name == 'user_subscriptions' %}👥{% elif tab.name == 'certificates' %}🔒{% elif tab.name == 'inbound_templates' %}⚙️{% endif %} {{ tab.label }}
</a>
{% endfor %}
</div>
</div>
{% endif %}
{{ block.super }}
{% endblock %}

View File

@@ -0,0 +1,18 @@
{% extends "admin/change_form.html" %}
{% block content %}
{% if show_tab_navigation %}
<div class="module" style="margin-bottom: 20px;">
<div style="display: flex; border-bottom: 1px solid #ddd;">
{% for tab in tabs %}
<a href="{{ tab.url }}"
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid {% if tab.active %}#417690{% else %}transparent{% endif %}; color: {% if tab.active %}#417690{% else %}#666{% endif %};">
{% if tab.name == 'subscription_groups' %}📋{% elif tab.name == 'user_subscriptions' %}👥{% elif tab.name == 'certificates' %}🔒{% elif tab.name == 'inbound_templates' %}⚙️{% endif %} {{ tab.label }}
</a>
{% endfor %}
</div>
</div>
{% endif %}
{{ block.super }}
{% endblock %}

View File

@@ -0,0 +1,18 @@
{% extends "admin/change_list.html" %}
{% block content %}
{% if show_tab_navigation %}
<div class="module" style="margin-bottom: 20px;">
<div style="display: flex; border-bottom: 1px solid #ddd;">
{% for tab in tabs %}
<a href="{{ tab.url }}"
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid {% if tab.active %}#417690{% else %}transparent{% endif %}; color: {% if tab.active %}#417690{% else %}#666{% endif %};">
{% if tab.name == 'subscription_groups' %}📋{% elif tab.name == 'user_subscriptions' %}👥{% elif tab.name == 'certificates' %}🔒{% elif tab.name == 'inbound_templates' %}⚙️{% endif %} {{ tab.label }}
</a>
{% endfor %}
</div>
</div>
{% endif %}
{{ block.super }}
{% endblock %}

View File

@@ -0,0 +1,39 @@
<!-- vpn/templates/admin/vpn/server/change_form.html -->
{% extends "admin/change_form.html" %}
{% load static %}
{% block extrahead %}
{{ block.super }}
<link rel="stylesheet" type="text/css" href="{% static 'admin/css/main.css' %}">
{% endblock %}
{% block submit_buttons_bottom %}
{{ block.super }}
{% if original %}
<div class="row mt-3">
<div class="col-12">
<div class="card card-outline card-primary">
<div class="card-header">
<h3 class="card-title">
<i class="fas fa-tools mr-2"></i>Server Actions
</h3>
</div>
<div class="card-body">
<div class="btn-group" role="group">
<a href="{% url 'admin:server_move_clients' %}?servers={{ original.id }}"
class="btn btn-primary">
<i class="fas fa-exchange-alt mr-2"></i>Move Links
</a>
<a href="{% url 'admin:server_purge_users' %}?servers={{ original.id }}"
class="btn btn-danger"
onclick="return confirm('Open purge interface for {{ original.name }}?')">
<i class="fas fa-trash mr-2"></i>Purge Users
</a>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,166 @@
<!-- vpn/templates/admin/vpn/server/change_list.html -->
{% extends "admin/change_list.html" %}
{% block content_title %}
<h1>{{ title }}</h1>
<!-- Bulk Action Buttons -->
<div class="bulk-actions-section" style="margin: 20px 0; padding: 15px; background-color: #f8f9fa; border: 1px solid #dee2e6; border-radius: 5px;">
<h3 style="margin-top: 0; color: #495057;">🚀 Bulk Server Operations</h3>
<p style="margin-bottom: 15px; color: #6c757d;">
Perform operations on all available servers at once. These actions will include all servers in the system.
</p>
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<a href="{{ bulk_move_clients_url }}"
class="bulk-action-btn btn-move-clients"
style="background-color: #007cba; color: white; padding: 10px 15px; text-decoration: none; border-radius: 4px; display: inline-flex; align-items: center; gap: 5px;">
📦 <span>Bulk Move Clients</span>
</a>
<a href="{{ bulk_purge_users_url }}"
class="bulk-action-btn btn-purge-users"
style="background-color: #dc3545; color: white; padding: 10px 15px; text-decoration: none; border-radius: 4px; display: inline-flex; align-items: center; gap: 5px;"
onclick="return confirm('⚠️ Warning: This will open the purge interface for ALL servers. Continue?')">
🗑️ <span>Bulk Purge Users</span>
</a>
</div>
<div class="tip-section" style="margin-top: 10px; padding: 10px; background-color: #fff3cd; border-left: 4px solid #ffc107; border-radius: 3px;">
<small style="color: #856404;">
<strong>💡 Tip:</strong> 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.
</small>
</div>
</div>
{% endblock %}
{% block extrahead %}
{{ block.super }}
{% load static %}
<link rel="stylesheet" type="text/css" href="{% static 'admin/css/server_actions.css' %}">
<style>
/* Style for action buttons in the list */
.field-server_actions .button {
background-color: #007cba;
color: white;
border: none;
border-radius: 3px;
padding: 5px 8px;
font-size: 11px;
text-decoration: none;
display: inline-block;
margin: 2px;
transition: background-color 0.2s;
}
.field-server_actions .button:hover {
background-color: #005a8b;
color: white;
}
.field-server_actions .button[style*="background-color: #dc3545"] {
background-color: #dc3545 !important;
}
.field-server_actions .button[style*="background-color: #dc3545"]:hover {
background-color: #c82333 !important;
}
/* Make action column wider */
.field-server_actions {
min-width: 150px;
}
/* Responsive design for action buttons */
@media (max-width: 768px) {
.field-server_actions > div {
flex-direction: column;
}
.field-server_actions .button {
margin: 1px 0;
text-align: center;
}
}
/* Enhanced bulk action section styling */
.bulk-actions-section {
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border-left: 4px solid #007cba;
}
.bulk-actions-section h3 {
color: #007cba;
display: flex;
align-items: center;
gap: 8px;
}
/* Hover effects for bulk buttons */
.bulk-action-btn {
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.bulk-action-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
/* Status indicators */
.server-status-online {
color: #28a745;
font-weight: bold;
}
.server-status-error {
color: #dc3545;
font-weight: bold;
}
</style>
<script>
// Add loading states to bulk action buttons
document.addEventListener('DOMContentLoaded', function() {
const bulkButtons = document.querySelectorAll('.bulk-action-btn');
bulkButtons.forEach(button => {
button.addEventListener('click', function(e) {
// Don't prevent default, but add loading state
this.classList.add('loading');
this.style.pointerEvents = 'none';
// Remove loading state after a delay (in case user navigates back)
setTimeout(() => {
this.classList.remove('loading');
this.style.pointerEvents = 'auto';
}, 5000);
});
});
});
</script>
{% endblock %}
{% block result_list %}
<!-- Server Statistics -->
<div class="server-stats-section" style="margin: 15px 0; padding: 10px; background-color: #e8f4fd; border: 1px solid #bee5eb; border-radius: 4px;">
<div class="server-stats-grid" style="display: flex; gap: 20px; flex-wrap: wrap; align-items: center;">
<div class="stat-item">
<strong>📊 Server Overview:</strong>
</div>
<div class="stat-item">
<span class="stat-label" style="color: #007cba;">Total Servers:</span>
<strong class="stat-value">{{ cl.result_count }}</strong>
</div>
{% if cl.result_count > 0 %}
<div class="stat-item">
<span class="stat-label" style="color: #28a745;">Available Operations:</span>
<strong class="stat-value">Move Links, Purge Users, Bulk Actions</strong>
</div>
{% endif %}
</div>
</div>
{{ block.super }}
{% endblock %}

View File

@@ -4,14 +4,12 @@
{% if show_tab_navigation %}
<div class="module" style="margin-bottom: 20px;">
<div style="display: flex; border-bottom: 1px solid #ddd;">
<a href="{% url 'admin:vpn_subscriptiongroup_changelist' %}"
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid #417690; color: #417690;">
📋 Subscription Groups
</a>
<a href="/admin/vpn/usersubscription/"
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid transparent; color: #666;">
👥 User Subscriptions
{% for tab in tabs %}
<a href="{{ tab.url }}"
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid {% if tab.active %}#417690{% else %}transparent{% endif %}; color: {% if tab.active %}#417690{% else %}#666{% endif %};">
{% if tab.name == 'subscription_groups' %}📋{% elif tab.name == 'user_subscriptions' %}👥{% elif tab.name == 'certificates' %}🔒{% elif tab.name == 'inbound_templates' %}⚙️{% endif %} {{ tab.label }}
</a>
{% endfor %}
</div>
</div>
{% endif %}

View File

@@ -4,14 +4,12 @@
{% if show_tab_navigation %}
<div class="module" style="margin-bottom: 20px;">
<div style="display: flex; border-bottom: 1px solid #ddd;">
<a href="{% url 'admin:vpn_subscriptiongroup_changelist' %}"
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid {% if current_tab == 'subscription_groups' %}#417690{% else %}transparent{% endif %}; color: {% if current_tab == 'subscription_groups' %}#417690{% else %}#666{% endif %};">
📋 Subscription Groups
</a>
<a href="/admin/vpn/usersubscription/"
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid {% if current_tab == 'user_subscriptions' %}#417690{% else %}transparent{% endif %}; color: {% if current_tab == 'user_subscriptions' %}#417690{% else %}#666{% endif %};">
👥 User Subscriptions
{% for tab in tabs %}
<a href="{{ tab.url }}"
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid {% if tab.active %}#417690{% else %}transparent{% endif %}; color: {% if tab.active %}#417690{% else %}#666{% endif %};">
{% if tab.name == 'subscription_groups' %}📋{% elif tab.name == 'user_subscriptions' %}👥{% elif tab.name == 'certificates' %}🔒{% elif tab.name == 'inbound_templates' %}⚙️{% endif %} {{ tab.label }}
</a>
{% endfor %}
</div>
</div>
{% endif %}

View File

@@ -1,18 +1,18 @@
{% extends "admin/change_form.html" %}
{% block content %}
{% if show_tab_navigation %}
<div class="module" style="margin-bottom: 20px;">
<div style="display: flex; border-bottom: 1px solid #ddd;">
<a href="{% url 'admin:vpn_subscriptiongroup_changelist' %}"
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid transparent; color: #666;">
📋 Subscription Groups
</a>
<a href="/admin/vpn/usersubscription/"
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid #417690; color: #417690;">
👥 User Subscriptions
{% for tab in tabs %}
<a href="{{ tab.url }}"
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid {% if tab.active %}#417690{% else %}transparent{% endif %}; color: {% if tab.active %}#417690{% else %}#666{% endif %};">
{% if tab.name == 'subscription_groups' %}📋{% elif tab.name == 'user_subscriptions' %}👥{% elif tab.name == 'certificates' %}🔒{% elif tab.name == 'inbound_templates' %}⚙️{% endif %} {{ tab.label }}
</a>
{% endfor %}
</div>
</div>
{% endif %}
{{ block.super }}
{% endblock %}

View File

@@ -1,18 +1,18 @@
{% extends "admin/change_list.html" %}
{% block content %}
{% if show_tab_navigation %}
<div class="module" style="margin-bottom: 20px;">
<div style="display: flex; border-bottom: 1px solid #ddd;">
<a href="{% url 'admin:vpn_subscriptiongroup_changelist' %}"
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid transparent; color: #666;">
📋 Subscription Groups
</a>
<a href="/admin/vpn/usersubscription/"
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid #417690; color: #417690;">
👥 User Subscriptions
{% for tab in tabs %}
<a href="{{ tab.url }}"
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid {% if tab.active %}#417690{% else %}transparent{% endif %}; color: {% if tab.active %}#417690{% else %}#666{% endif %};">
{% if tab.name == 'subscription_groups' %}📋{% elif tab.name == 'user_subscriptions' %}👥{% elif tab.name == 'certificates' %}🔒{% elif tab.name == 'inbound_templates' %}⚙️{% endif %} {{ tab.label }}
</a>
{% endfor %}
</div>
</div>
{% endif %}
{{ block.super }}
{% endblock %}

21
vpn/utils.py Normal file
View File

@@ -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"<pre>{formatted_data}</pre>")
elif isinstance(data, str):
return mark_safe(f"<pre>{data}</pre>")
else:
return mark_safe(f"<pre>{str(data)}</pre>")
except Exception as e:
return mark_safe(f"<span style='color: red;'>Error: {e}</span>")