mirror of
https://github.com/house-of-vanity/OutFleet.git
synced 2025-08-21 14:37:16 +00:00
Added TG bot
This commit is contained in:
@@ -89,6 +89,11 @@ LOGGING = {
|
|||||||
'level': 'DEBUG',
|
'level': 'DEBUG',
|
||||||
'propagate': False,
|
'propagate': False,
|
||||||
},
|
},
|
||||||
|
'telegram_bot': {
|
||||||
|
'handlers': ['console'],
|
||||||
|
'level': 'DEBUG',
|
||||||
|
'propagate': False,
|
||||||
|
},
|
||||||
'requests': {
|
'requests': {
|
||||||
'handlers': ['console'],
|
'handlers': ['console'],
|
||||||
'level': 'INFO',
|
'level': 'INFO',
|
||||||
@@ -115,6 +120,7 @@ INSTALLED_APPS = [
|
|||||||
'django_celery_results',
|
'django_celery_results',
|
||||||
'django_celery_beat',
|
'django_celery_beat',
|
||||||
'vpn',
|
'vpn',
|
||||||
|
'telegram_bot',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
@@ -19,3 +19,4 @@ cryptography==45.0.5
|
|||||||
acme>=2.0.0
|
acme>=2.0.0
|
||||||
cloudflare>=4.3.1
|
cloudflare>=4.3.1
|
||||||
josepy>=2.0.0
|
josepy>=2.0.0
|
||||||
|
python-telegram-bot==21.10
|
||||||
|
239
static/admin/css/main.css
Normal file
239
static/admin/css/main.css
Normal 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
0
telegram_bot/__init__.py
Normal file
621
telegram_bot/admin.py
Normal file
621
telegram_bot/admin.py
Normal 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
75
telegram_bot/apps.py
Normal 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
1060
telegram_bot/bot.py
Normal file
File diff suppressed because it is too large
Load Diff
207
telegram_bot/localization.py
Normal file
207
telegram_bot/localization.py
Normal 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)
|
0
telegram_bot/management/__init__.py
Normal file
0
telegram_bot/management/__init__.py
Normal file
0
telegram_bot/management/commands/__init__.py
Normal file
0
telegram_bot/management/commands/__init__.py
Normal file
99
telegram_bot/management/commands/run_telegram_bot.py
Normal file
99
telegram_bot/management/commands/run_telegram_bot.py
Normal 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
|
112
telegram_bot/management/commands/telegram_bot_status.py
Normal file
112
telegram_bot/management/commands/telegram_bot_status.py
Normal 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}")
|
||||||
|
)
|
70
telegram_bot/migrations/0001_initial.py
Normal file
70
telegram_bot/migrations/0001_initial.py
Normal 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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
33
telegram_bot/migrations/0002_add_connection_settings.py
Normal file
33
telegram_bot/migrations/0002_add_connection_settings.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
42
telegram_bot/migrations/0003_accessrequest.py
Normal file
42
telegram_bot/migrations/0003_accessrequest.py
Normal 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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
0
telegram_bot/migrations/__init__.py
Normal file
0
telegram_bot/migrations/__init__.py
Normal file
292
telegram_bot/models.py
Normal file
292
telegram_bot/models.py
Normal 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
3
telegram_bot/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
3
telegram_bot/views.py
Normal file
3
telegram_bot/views.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
# Create your views here.
|
0
telegram_bot_locks/telegram_bot.lock
Normal file
0
telegram_bot_locks/telegram_bot.lock
Normal file
2092
vpn/admin.py
2092
vpn/admin.py
File diff suppressed because it is too large
Load Diff
45
vpn/admin/__init__.py
Normal file
45
vpn/admin/__init__.py
Normal 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
485
vpn/admin/access.py
Normal 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
57
vpn/admin/base.py
Normal 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
179
vpn/admin/logs.py
Normal 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
826
vpn/admin/server.py
Normal 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
604
vpn/admin/user.py
Normal 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
73
vpn/admin_minimal.py
Normal 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
16
vpn/admin_test.py
Normal 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!")
|
@@ -3,6 +3,27 @@ Admin interface for new Xray models.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
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.contrib import admin, messages
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.html import format_html
|
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.urls import path, reverse
|
||||||
from django.http import JsonResponse, HttpResponseRedirect
|
from django.http import JsonResponse, HttpResponseRedirect
|
||||||
|
|
||||||
from .models_xray import (
|
try:
|
||||||
Credentials, Certificate,
|
from .models_xray import (
|
||||||
Inbound, SubscriptionGroup, UserSubscription, ServerInbound
|
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
|
return False
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Certificate)
|
|
||||||
class CertificateAdmin(admin.ModelAdmin):
|
class CertificateAdmin(admin.ModelAdmin):
|
||||||
"""Admin for certificate management"""
|
"""Admin for certificate management"""
|
||||||
list_display = (
|
list_display = (
|
||||||
@@ -445,7 +471,6 @@ class CertificateAdmin(admin.ModelAdmin):
|
|||||||
rotate_selected_certificates.short_description = "🔄 Rotate selected Let's Encrypt certificates"
|
rotate_selected_certificates.short_description = "🔄 Rotate selected Let's Encrypt certificates"
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Inbound)
|
|
||||||
class InboundAdmin(admin.ModelAdmin):
|
class InboundAdmin(admin.ModelAdmin):
|
||||||
"""Admin for inbound template management"""
|
"""Admin for inbound template management"""
|
||||||
list_display = (
|
list_display = (
|
||||||
@@ -673,10 +698,10 @@ class ServerInboundAdmin(admin.ModelAdmin):
|
|||||||
certificate_info.short_description = 'Certificate Selection Info'
|
certificate_info.short_description = 'Certificate Selection Info'
|
||||||
|
|
||||||
|
|
||||||
# Unified Subscriptions Admin with tabs
|
# Unified XRay-core Admin with tabs
|
||||||
@admin.register(SubscriptionGroup)
|
@admin.register(SubscriptionGroup)
|
||||||
class UnifiedSubscriptionsAdmin(admin.ModelAdmin):
|
class UnifiedXRayAdmin(admin.ModelAdmin):
|
||||||
"""Unified admin for managing both Subscription Groups and User Subscriptions"""
|
"""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
|
# Use SubscriptionGroup as the base model but provide access to UserSubscription via tabs
|
||||||
list_display = ('name', 'is_active', 'inbound_count', 'user_count', 'created_at')
|
list_display = ('name', 'is_active', 'inbound_count', 'user_count', 'created_at')
|
||||||
@@ -685,12 +710,18 @@ class UnifiedSubscriptionsAdmin(admin.ModelAdmin):
|
|||||||
filter_horizontal = ('inbounds',)
|
filter_horizontal = ('inbounds',)
|
||||||
|
|
||||||
def get_urls(self):
|
def get_urls(self):
|
||||||
"""Add custom URLs for user subscriptions tab"""
|
"""Add custom URLs for additional tabs"""
|
||||||
urls = super().get_urls()
|
urls = super().get_urls()
|
||||||
custom_urls = [
|
custom_urls = [
|
||||||
path('user-subscriptions/',
|
path('user-subscriptions/',
|
||||||
self.admin_site.admin_view(self.user_subscriptions_view),
|
self.admin_site.admin_view(self.user_subscriptions_view),
|
||||||
name='vpn_usersubscription_changelist_tab'),
|
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
|
return custom_urls + urls
|
||||||
|
|
||||||
@@ -699,12 +730,28 @@ class UnifiedSubscriptionsAdmin(admin.ModelAdmin):
|
|||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
return redirect('/admin/vpn/usersubscription/')
|
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):
|
def changelist_view(self, request, extra_context=None):
|
||||||
"""Override changelist to add tab navigation"""
|
"""Override changelist to add tab navigation"""
|
||||||
extra_context = extra_context or {}
|
extra_context = extra_context or {}
|
||||||
extra_context.update({
|
extra_context.update({
|
||||||
'show_tab_navigation': True,
|
'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)
|
return super().changelist_view(request, extra_context)
|
||||||
|
|
||||||
@@ -713,7 +760,13 @@ class UnifiedSubscriptionsAdmin(admin.ModelAdmin):
|
|||||||
extra_context = extra_context or {}
|
extra_context = extra_context or {}
|
||||||
extra_context.update({
|
extra_context.update({
|
||||||
'show_tab_navigation': True,
|
'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)
|
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 = extra_context or {}
|
||||||
extra_context.update({
|
extra_context.update({
|
||||||
'show_tab_navigation': True,
|
'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)
|
return super().add_view(request, form_url, extra_context)
|
||||||
|
|
||||||
@@ -810,7 +869,13 @@ class UserSubscriptionTabAdmin(UserSubscriptionAdmin):
|
|||||||
extra_context = extra_context or {}
|
extra_context = extra_context or {}
|
||||||
extra_context.update({
|
extra_context.update({
|
||||||
'show_tab_navigation': True,
|
'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)
|
return super().changelist_view(request, extra_context)
|
||||||
|
|
||||||
@@ -819,7 +884,13 @@ class UserSubscriptionTabAdmin(UserSubscriptionAdmin):
|
|||||||
extra_context = extra_context or {}
|
extra_context = extra_context or {}
|
||||||
extra_context.update({
|
extra_context.update({
|
||||||
'show_tab_navigation': True,
|
'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)
|
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 = extra_context or {}
|
||||||
extra_context.update({
|
extra_context.update({
|
||||||
'show_tab_navigation': True,
|
'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
|
||||||
|
96
vpn/apps.py
96
vpn/apps.py
@@ -7,7 +7,103 @@ class VPN(AppConfig):
|
|||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
"""Import signals when Django starts"""
|
"""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:
|
try:
|
||||||
import vpn.signals # noqa
|
import vpn.signals # noqa
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
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")
|
||||||
|
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
17
vpn/migrations/0026_alter_subscriptiongroup_options.py
Normal file
17
vpn/migrations/0026_alter_subscriptiongroup_options.py
Normal 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'},
|
||||||
|
),
|
||||||
|
]
|
@@ -95,6 +95,38 @@ class User(AbstractUser):
|
|||||||
last_access = models.DateTimeField(null=True, blank=True)
|
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.")
|
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):
|
def get_servers(self):
|
||||||
return Server.objects.filter(acl__user=self)
|
return Server.objects.filter(acl__user=self)
|
||||||
|
|
||||||
|
@@ -362,8 +362,8 @@ class SubscriptionGroup(models.Model):
|
|||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Subscriptions"
|
verbose_name = "XRay-core"
|
||||||
verbose_name_plural = "Subscriptions"
|
verbose_name_plural = "XRay-core"
|
||||||
ordering = ['name']
|
ordering = ['name']
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from polymorphic.admin import PolymorphicChildModelAdmin
|
||||||
from .generic import Server
|
from .generic import Server
|
||||||
from vpn.models_xray import Inbound, UserSubscription
|
from vpn.models_xray import Inbound, UserSubscription
|
||||||
|
|
||||||
@@ -778,7 +779,9 @@ class ServerInboundInline(admin.TabularInline):
|
|||||||
return super().formfield_for_foreignkey(db_field, request, **kwargs)
|
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_display = ['name', 'client_hostname', 'api_address', 'api_enabled', 'stats_enabled', 'registration_date']
|
||||||
list_filter = ['api_enabled', 'stats_enabled', 'registration_date']
|
list_filter = ['api_enabled', 'stats_enabled', 'registration_date']
|
||||||
search_fields = ['name', 'client_hostname', 'comment']
|
search_fields = ['name', 'client_hostname', 'comment']
|
||||||
@@ -945,3 +948,7 @@ class XrayServerV2Admin(admin.ModelAdmin):
|
|||||||
return f"Error fetching statistics: {str(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)
|
304
vpn/templates/admin/purge_users.html
Normal file
304
vpn/templates/admin/purge_users.html
Normal 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 %}
|
18
vpn/templates/admin/vpn/certificate/change_form.html
Normal file
18
vpn/templates/admin/vpn/certificate/change_form.html
Normal 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 %}
|
18
vpn/templates/admin/vpn/certificate/change_list.html
Normal file
18
vpn/templates/admin/vpn/certificate/change_list.html
Normal 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 %}
|
18
vpn/templates/admin/vpn/inbound/change_form.html
Normal file
18
vpn/templates/admin/vpn/inbound/change_form.html
Normal 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 %}
|
18
vpn/templates/admin/vpn/inbound/change_list.html
Normal file
18
vpn/templates/admin/vpn/inbound/change_list.html
Normal 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 %}
|
39
vpn/templates/admin/vpn/server/change_form.html
Normal file
39
vpn/templates/admin/vpn/server/change_form.html
Normal 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 %}
|
166
vpn/templates/admin/vpn/server/change_list.html
Normal file
166
vpn/templates/admin/vpn/server/change_list.html
Normal 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 %}
|
@@ -4,14 +4,12 @@
|
|||||||
{% if show_tab_navigation %}
|
{% if show_tab_navigation %}
|
||||||
<div class="module" style="margin-bottom: 20px;">
|
<div class="module" style="margin-bottom: 20px;">
|
||||||
<div style="display: flex; border-bottom: 1px solid #ddd;">
|
<div style="display: flex; border-bottom: 1px solid #ddd;">
|
||||||
<a href="{% url 'admin:vpn_subscriptiongroup_changelist' %}"
|
{% for tab in tabs %}
|
||||||
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid #417690; color: #417690;">
|
<a href="{{ tab.url }}"
|
||||||
📋 Subscription Groups
|
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 %};">
|
||||||
</a>
|
{% if tab.name == 'subscription_groups' %}📋{% elif tab.name == 'user_subscriptions' %}👥{% elif tab.name == 'certificates' %}🔒{% elif tab.name == 'inbound_templates' %}⚙️{% endif %} {{ tab.label }}
|
||||||
<a href="/admin/vpn/usersubscription/"
|
|
||||||
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid transparent; color: #666;">
|
|
||||||
👥 User Subscriptions
|
|
||||||
</a>
|
</a>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@@ -4,14 +4,12 @@
|
|||||||
{% if show_tab_navigation %}
|
{% if show_tab_navigation %}
|
||||||
<div class="module" style="margin-bottom: 20px;">
|
<div class="module" style="margin-bottom: 20px;">
|
||||||
<div style="display: flex; border-bottom: 1px solid #ddd;">
|
<div style="display: flex; border-bottom: 1px solid #ddd;">
|
||||||
<a href="{% url 'admin:vpn_subscriptiongroup_changelist' %}"
|
{% for tab in tabs %}
|
||||||
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 %};">
|
<a href="{{ tab.url }}"
|
||||||
📋 Subscription Groups
|
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 %};">
|
||||||
</a>
|
{% if tab.name == 'subscription_groups' %}📋{% elif tab.name == 'user_subscriptions' %}👥{% elif tab.name == 'certificates' %}🔒{% elif tab.name == 'inbound_templates' %}⚙️{% endif %} {{ tab.label }}
|
||||||
<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
|
|
||||||
</a>
|
</a>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@@ -1,18 +1,18 @@
|
|||||||
{% extends "admin/change_form.html" %}
|
{% extends "admin/change_form.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% if show_tab_navigation %}
|
||||||
<div class="module" style="margin-bottom: 20px;">
|
<div class="module" style="margin-bottom: 20px;">
|
||||||
<div style="display: flex; border-bottom: 1px solid #ddd;">
|
<div style="display: flex; border-bottom: 1px solid #ddd;">
|
||||||
<a href="{% url 'admin:vpn_subscriptiongroup_changelist' %}"
|
{% for tab in tabs %}
|
||||||
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid transparent; color: #666;">
|
<a href="{{ tab.url }}"
|
||||||
📋 Subscription Groups
|
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 %};">
|
||||||
</a>
|
{% if tab.name == 'subscription_groups' %}📋{% elif tab.name == 'user_subscriptions' %}👥{% elif tab.name == 'certificates' %}🔒{% elif tab.name == 'inbound_templates' %}⚙️{% endif %} {{ tab.label }}
|
||||||
<a href="/admin/vpn/usersubscription/"
|
|
||||||
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid #417690; color: #417690;">
|
|
||||||
👥 User Subscriptions
|
|
||||||
</a>
|
</a>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
{% endblock %}
|
{% endblock %}
|
@@ -1,18 +1,18 @@
|
|||||||
{% extends "admin/change_list.html" %}
|
{% extends "admin/change_list.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% if show_tab_navigation %}
|
||||||
<div class="module" style="margin-bottom: 20px;">
|
<div class="module" style="margin-bottom: 20px;">
|
||||||
<div style="display: flex; border-bottom: 1px solid #ddd;">
|
<div style="display: flex; border-bottom: 1px solid #ddd;">
|
||||||
<a href="{% url 'admin:vpn_subscriptiongroup_changelist' %}"
|
{% for tab in tabs %}
|
||||||
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid transparent; color: #666;">
|
<a href="{{ tab.url }}"
|
||||||
📋 Subscription Groups
|
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 %};">
|
||||||
</a>
|
{% if tab.name == 'subscription_groups' %}📋{% elif tab.name == 'user_subscriptions' %}👥{% elif tab.name == 'certificates' %}🔒{% elif tab.name == 'inbound_templates' %}⚙️{% endif %} {{ tab.label }}
|
||||||
<a href="/admin/vpn/usersubscription/"
|
|
||||||
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid #417690; color: #417690;">
|
|
||||||
👥 User Subscriptions
|
|
||||||
</a>
|
</a>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
{% endblock %}
|
{% endblock %}
|
21
vpn/utils.py
Normal file
21
vpn/utils.py
Normal 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>")
|
Reference in New Issue
Block a user