2024-10-20 21:57:12 +00:00
|
|
|
|
import json
|
2025-07-21 15:30:57 +03:00
|
|
|
|
import shortuuid
|
2024-10-20 21:57:12 +00:00
|
|
|
|
from polymorphic.admin import (
|
|
|
|
|
PolymorphicParentModelAdmin,
|
|
|
|
|
)
|
|
|
|
|
from django.contrib import admin
|
|
|
|
|
from django.utils.safestring import mark_safe
|
2025-03-04 17:18:51 +00:00
|
|
|
|
from django.utils.html import format_html
|
2024-10-20 21:57:12 +00:00
|
|
|
|
from django.db.models import Count
|
2025-06-27 16:02:13 +03:00
|
|
|
|
from django.shortcuts import render, redirect
|
|
|
|
|
from django.contrib import messages
|
|
|
|
|
from django.urls import path, reverse
|
|
|
|
|
from django.http import HttpResponseRedirect
|
2024-10-20 21:57:12 +00:00
|
|
|
|
|
2024-10-27 01:06:37 +00:00
|
|
|
|
from django.contrib.auth.admin import UserAdmin
|
2025-07-21 13:23:10 +03:00
|
|
|
|
from .models import User, AccessLog, TaskExecutionLog, UserStatistics
|
2024-10-28 17:15:49 +00:00
|
|
|
|
from django.utils.timezone import localtime
|
|
|
|
|
from vpn.models import User, ACL, ACLLink
|
2024-10-20 21:57:12 +00:00
|
|
|
|
from vpn.forms import UserForm
|
2024-11-18 20:34:54 +00:00
|
|
|
|
from mysite.settings import EXTERNAL_ADDRESS
|
2025-07-21 03:32:37 +03:00
|
|
|
|
from django.db.models import Max, Subquery, OuterRef, Q
|
2024-10-20 21:57:12 +00:00
|
|
|
|
from .server_plugins import (
|
|
|
|
|
Server,
|
|
|
|
|
WireguardServer,
|
|
|
|
|
WireguardServerAdmin,
|
|
|
|
|
OutlineServer,
|
2025-07-27 20:37:21 +03:00
|
|
|
|
OutlineServerAdmin,
|
2025-08-08 05:46:36 +03:00
|
|
|
|
XrayServerV2,
|
|
|
|
|
XrayServerV2Admin)
|
|
|
|
|
|
|
|
|
|
# Import new Xray admin configuration
|
|
|
|
|
from .admin_xray import add_subscription_management_to_user
|
|
|
|
|
|
|
|
|
|
# This will be registered at the end of the file
|
2024-10-20 21:57:12 +00:00
|
|
|
|
|
2025-07-21 13:23:10 +03:00
|
|
|
|
|
2025-07-20 22:50:22 +03:00
|
|
|
|
@admin.register(TaskExecutionLog)
|
|
|
|
|
class TaskExecutionLogAdmin(admin.ModelAdmin):
|
|
|
|
|
list_display = ('task_name_display', 'action', 'status_display', 'server', 'user', 'execution_time_display', 'created_at')
|
|
|
|
|
list_filter = ('task_name', 'status', 'server', 'created_at')
|
|
|
|
|
search_fields = ('task_id', 'task_name', 'action', 'user__username', 'server__name', 'message')
|
|
|
|
|
readonly_fields = ('task_id', 'task_name', 'server', 'user', 'action', 'status', 'message_formatted', 'execution_time', 'created_at')
|
|
|
|
|
ordering = ('-created_at',)
|
|
|
|
|
list_per_page = 100
|
|
|
|
|
date_hierarchy = 'created_at'
|
2025-07-21 13:23:10 +03:00
|
|
|
|
actions = ['trigger_full_sync', 'trigger_statistics_update']
|
2025-07-20 22:50:22 +03:00
|
|
|
|
|
|
|
|
|
fieldsets = (
|
|
|
|
|
('Task Information', {
|
|
|
|
|
'fields': ('task_id', 'task_name', 'action', 'status')
|
|
|
|
|
}),
|
|
|
|
|
('Related Objects', {
|
|
|
|
|
'fields': ('server', 'user')
|
|
|
|
|
}),
|
|
|
|
|
('Execution Details', {
|
|
|
|
|
'fields': ('message_formatted', 'execution_time', 'created_at')
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
2025-07-21 03:32:37 +03:00
|
|
|
|
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"
|
|
|
|
|
|
2025-07-21 13:23:10 +03:00
|
|
|
|
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"
|
|
|
|
|
|
2025-07-21 03:32:37 +03:00
|
|
|
|
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
|
|
|
|
|
|
2025-07-20 22:50:22 +03:00
|
|
|
|
@admin.display(description='Task', ordering='task_name')
|
|
|
|
|
def task_name_display(self, obj):
|
|
|
|
|
task_names = {
|
|
|
|
|
'sync_all_servers': '🔄 Sync All',
|
|
|
|
|
'sync_all_users_on_server': '👥 Server Sync',
|
|
|
|
|
'sync_server_info': '⚙️ Server Info',
|
|
|
|
|
'sync_user_on_server': '👤 User Sync',
|
|
|
|
|
'cleanup_task_logs': '🧹 Cleanup',
|
2025-07-21 13:23:10 +03:00
|
|
|
|
'update_user_statistics': '📊 Statistics',
|
2025-07-20 22:50:22 +03:00
|
|
|
|
}
|
|
|
|
|
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
|
2025-07-21 03:32:37 +03:00
|
|
|
|
|
|
|
|
|
def changelist_view(self, request, extra_context=None):
|
2025-07-21 03:48:35 +03:00
|
|
|
|
"""Override to handle actions that don't require item selection"""
|
2025-07-21 03:32:37 +03:00
|
|
|
|
# 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())
|
2025-07-21 13:23:10 +03:00
|
|
|
|
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())
|
2025-07-21 03:32:37 +03:00
|
|
|
|
|
|
|
|
|
return super().changelist_view(request, extra_context)
|
2025-07-20 22:50:22 +03:00
|
|
|
|
|
2024-10-20 21:57:12 +00:00
|
|
|
|
|
2024-10-28 00:06:35 +00:00
|
|
|
|
admin.site.site_title = "VPN Manager"
|
|
|
|
|
admin.site.site_header = "VPN Manager"
|
|
|
|
|
admin.site.index_title = "OutFleet"
|
|
|
|
|
|
2024-10-28 17:15:49 +00:00
|
|
|
|
def format_object(data):
|
|
|
|
|
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>")
|
|
|
|
|
|
|
|
|
|
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
|
2024-10-28 00:06:35 +00:00
|
|
|
|
|
2025-07-21 03:32:37 +03:00
|
|
|
|
|
|
|
|
|
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':
|
2025-07-21 12:12:31 +03:00
|
|
|
|
# Links that have never been accessed
|
|
|
|
|
return queryset.filter(last_access_time__isnull=True)
|
2025-07-21 03:32:37 +03:00
|
|
|
|
elif self.value() == 'week':
|
|
|
|
|
# Links accessed in the last week
|
|
|
|
|
week_ago = timezone.now() - timedelta(days=7)
|
2025-07-21 12:12:31 +03:00
|
|
|
|
return queryset.filter(last_access_time__gte=week_ago)
|
2025-07-21 03:32:37 +03:00
|
|
|
|
elif self.value() == 'month':
|
|
|
|
|
# Links accessed in the last month
|
|
|
|
|
month_ago = timezone.now() - timedelta(days=30)
|
2025-07-21 12:12:31 +03:00
|
|
|
|
return queryset.filter(last_access_time__gte=month_ago)
|
2025-07-21 03:32:37 +03:00
|
|
|
|
elif self.value() == 'old':
|
|
|
|
|
# Links not accessed for more than 3 months
|
|
|
|
|
three_months_ago = timezone.now() - timedelta(days=90)
|
2025-07-21 12:12:31 +03:00
|
|
|
|
return queryset.filter(last_access_time__lt=three_months_ago)
|
2025-07-21 03:32:37 +03:00
|
|
|
|
return queryset
|
|
|
|
|
|
2024-10-20 21:57:12 +00:00
|
|
|
|
@admin.register(Server)
|
|
|
|
|
class ServerAdmin(PolymorphicParentModelAdmin):
|
|
|
|
|
base_model = Server
|
2025-08-08 05:46:36 +03:00
|
|
|
|
child_models = (OutlineServer, WireguardServer, XrayServerV2)
|
2025-07-21 18:26:29 +03:00
|
|
|
|
list_display = ('name_with_icon', 'server_type', 'comment_short', 'user_stats', 'server_status_compact', 'registration_date')
|
2024-10-20 21:57:12 +00:00
|
|
|
|
search_fields = ('name', 'comment')
|
|
|
|
|
list_filter = ('server_type', )
|
2025-08-08 05:46:36 +03:00
|
|
|
|
actions = ['move_clients_action', 'purge_all_keys_action', 'sync_all_selected_servers', 'sync_xray_inbounds', 'check_status']
|
2025-07-21 18:26:29 +03:00
|
|
|
|
|
|
|
|
|
class Media:
|
|
|
|
|
css = {
|
2025-07-21 18:55:59 +03:00
|
|
|
|
'all': ('admin/css/vpn_admin.css',)
|
2025-07-21 18:26:29 +03:00
|
|
|
|
}
|
2025-07-21 18:55:59 +03:00
|
|
|
|
js = ('admin/js/server_status_check.js',)
|
2025-06-27 16:02:13 +03:00
|
|
|
|
|
|
|
|
|
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'),
|
2025-07-21 18:55:59 +03:00
|
|
|
|
path('<int:server_id>/check-status/', self.admin_site.admin_view(self.check_server_status_view), name='server_check_status'),
|
2025-08-05 01:23:07 +03:00
|
|
|
|
path('<int:object_id>/sync/', self.admin_site.admin_view(self.sync_server_view), name='server_sync'),
|
2025-06-27 16:02:13 +03:00
|
|
|
|
]
|
|
|
|
|
return custom_urls + urls
|
|
|
|
|
|
|
|
|
|
def move_clients_action(self, request, queryset):
|
2025-06-27 17:08:32 +03:00
|
|
|
|
"""Кастомное действие для перехода к странице переноса клиентов"""
|
2025-06-27 16:02:13 +03:00
|
|
|
|
if queryset.count() == 0:
|
2025-06-27 17:08:32 +03:00
|
|
|
|
self.message_user(request, "Выберите хотя бы один сервер.", level=messages.ERROR)
|
2025-06-27 16:02:13 +03:00
|
|
|
|
return
|
|
|
|
|
|
2025-06-27 17:08:32 +03:00
|
|
|
|
# Перенаправляем на страницу переноса клиентов
|
2025-06-27 16:02:13 +03:00
|
|
|
|
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:
|
2025-06-27 16:20:31 +03:00
|
|
|
|
# Only work with database objects, don't check server connectivity
|
2025-06-27 16:02:13 +03:00
|
|
|
|
servers = Server.objects.filter(id__in=server_ids)
|
|
|
|
|
all_servers = Server.objects.all()
|
|
|
|
|
|
|
|
|
|
# Get ACL links for selected servers with related data
|
2025-06-27 16:20:31 +03:00
|
|
|
|
# This is purely database operation, no server connectivity required
|
2025-06-27 16:02:13 +03:00
|
|
|
|
links_by_server = {}
|
|
|
|
|
for server in servers:
|
2025-06-27 16:20:31 +03:00
|
|
|
|
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] = []
|
2025-06-27 16:02:13 +03:00
|
|
|
|
|
|
|
|
|
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:
|
2025-06-27 16:20:31 +03:00
|
|
|
|
messages.error(request, f"Database error while loading data: {e}")
|
2025-06-27 16:02:13 +03:00
|
|
|
|
return redirect('admin:vpn_server_changelist')
|
|
|
|
|
|
|
|
|
|
elif request.method == 'POST':
|
2025-06-27 16:20:31 +03:00
|
|
|
|
# Process the transfer of ACL links - purely database operations
|
2025-06-27 16:02:13 +03:00
|
|
|
|
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')
|
2025-06-27 17:08:32 +03:00
|
|
|
|
comment_regex = request.POST.get('comment_regex', '').strip()
|
2025-06-27 16:02:13 +03:00
|
|
|
|
|
|
|
|
|
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())
|
|
|
|
|
|
2025-06-27 17:08:32 +03:00
|
|
|
|
# Parse and validate regex pattern if provided
|
|
|
|
|
regex_pattern = None
|
|
|
|
|
regex_replacement = None
|
2025-06-27 17:21:54 +03:00
|
|
|
|
regex_parts = None
|
2025-06-27 17:08:32 +03:00
|
|
|
|
if comment_regex:
|
|
|
|
|
try:
|
|
|
|
|
import re
|
2025-06-27 17:21:54 +03:00
|
|
|
|
regex_parts = comment_regex.split(' -> ')
|
|
|
|
|
if len(regex_parts) != 2:
|
2025-06-27 17:08:32 +03:00
|
|
|
|
messages.error(request, "Invalid regex format. Use: pattern -> replacement")
|
|
|
|
|
return redirect(request.get_full_path())
|
|
|
|
|
|
2025-06-27 17:21:54 +03:00
|
|
|
|
pattern_str = regex_parts[0]
|
|
|
|
|
replacement_str = regex_parts[1]
|
|
|
|
|
|
|
|
|
|
# Convert JavaScript-style $1, $2, $3 to Python-style \1, \2, \3
|
|
|
|
|
python_replacement = replacement_str
|
|
|
|
|
import re as regex_module
|
|
|
|
|
# Replace $1, $2, etc. with \1, \2, etc. for Python regex
|
|
|
|
|
python_replacement = regex_module.sub(r'\$(\d+)', r'\\\1', replacement_str)
|
|
|
|
|
|
|
|
|
|
# Test compile the regex pattern
|
|
|
|
|
regex_pattern = re.compile(pattern_str)
|
|
|
|
|
regex_replacement = python_replacement
|
|
|
|
|
|
|
|
|
|
# Test the replacement on a sample string to validate syntax
|
|
|
|
|
test_result = regex_pattern.sub(regex_replacement, "test sample")
|
2025-06-27 17:08:32 +03:00
|
|
|
|
|
|
|
|
|
except re.error as e:
|
2025-06-27 17:21:54 +03:00
|
|
|
|
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}")
|
2025-06-27 17:08:32 +03:00
|
|
|
|
return redirect(request.get_full_path())
|
|
|
|
|
|
2025-06-27 16:20:31 +03:00
|
|
|
|
# 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')
|
2025-06-27 16:02:13 +03:00
|
|
|
|
|
|
|
|
|
moved_count = 0
|
|
|
|
|
errors = []
|
|
|
|
|
users_processed = set()
|
2025-06-27 17:08:32 +03:00
|
|
|
|
comments_transformed = 0
|
2025-06-27 16:02:13 +03:00
|
|
|
|
|
2025-06-27 16:20:31 +03:00
|
|
|
|
# Process each selected link - database operations only
|
2025-06-27 16:02:13 +03:00
|
|
|
|
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
|
|
|
|
|
|
2025-06-27 17:08:32 +03:00
|
|
|
|
# Apply regex transformation to comment if provided
|
|
|
|
|
original_comment = acl_link.comment
|
|
|
|
|
if regex_pattern and regex_replacement is not None:
|
|
|
|
|
try:
|
2025-06-27 17:21:54 +03:00
|
|
|
|
# Use Python's re.sub for replacement, which properly handles $1, $2 groups
|
2025-06-27 17:08:32 +03:00
|
|
|
|
new_comment = regex_pattern.sub(regex_replacement, original_comment)
|
|
|
|
|
if new_comment != original_comment:
|
|
|
|
|
acl_link.comment = new_comment
|
|
|
|
|
comments_transformed += 1
|
2025-06-27 17:21:54 +03:00
|
|
|
|
# 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}'")
|
2025-06-27 17:08:32 +03:00
|
|
|
|
except Exception as e:
|
2025-06-27 17:21:54 +03:00
|
|
|
|
errors.append(f"Error applying regex to link {link_id} ('{original_comment}'): {e}")
|
2025-06-27 17:08:32 +03:00
|
|
|
|
# Continue with original comment
|
|
|
|
|
|
2025-06-27 16:02:13 +03:00
|
|
|
|
# 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
|
|
|
|
|
|
2025-06-27 16:20:31 +03:00
|
|
|
|
# Move the link to target ACL - pure database operation
|
2025-06-27 16:02:13 +03:00
|
|
|
|
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:
|
2025-06-27 16:20:31 +03:00
|
|
|
|
errors.append(f"Database error moving link {link_id}: {e}")
|
2025-06-27 16:02:13 +03:00
|
|
|
|
|
2025-06-27 16:20:31 +03:00
|
|
|
|
# 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
|
2025-06-27 16:02:13 +03:00
|
|
|
|
|
|
|
|
|
if moved_count > 0:
|
2025-06-27 17:08:32 +03:00
|
|
|
|
success_msg = (
|
2025-06-27 16:02:13 +03:00
|
|
|
|
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)."
|
|
|
|
|
)
|
2025-06-27 17:08:32 +03:00
|
|
|
|
if comments_transformed > 0:
|
|
|
|
|
success_msg += f" Transformed {comments_transformed} comment(s) using regex."
|
|
|
|
|
|
|
|
|
|
messages.success(request, success_msg)
|
2025-06-27 16:02:13 +03:00
|
|
|
|
|
|
|
|
|
if errors:
|
|
|
|
|
for error in errors:
|
|
|
|
|
messages.error(request, error)
|
|
|
|
|
|
|
|
|
|
return redirect('admin:vpn_server_changelist')
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
2025-06-27 16:20:31 +03:00
|
|
|
|
messages.error(request, f"Database error during link transfer: {e}")
|
2025-06-27 16:02:13 +03:00
|
|
|
|
return redirect('admin:vpn_server_changelist')
|
2025-07-21 18:55:59 +03:00
|
|
|
|
|
|
|
|
|
def check_server_status_view(self, request, server_id):
|
|
|
|
|
"""AJAX view to check server status"""
|
|
|
|
|
from django.http import JsonResponse
|
|
|
|
|
import logging
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
if request.method == 'POST':
|
|
|
|
|
try:
|
|
|
|
|
logger.info(f"Checking status for server ID: {server_id}")
|
|
|
|
|
server = Server.objects.get(pk=server_id)
|
|
|
|
|
real_server = server.get_real_instance()
|
|
|
|
|
logger.info(f"Server found: {server.name}, type: {type(real_server).__name__}")
|
|
|
|
|
|
|
|
|
|
# Check server status based on type
|
|
|
|
|
from vpn.server_plugins.outline import OutlineServer
|
2025-08-08 05:46:36 +03:00
|
|
|
|
# Old xray_core module removed - skip this server type
|
2025-07-21 18:55:59 +03:00
|
|
|
|
|
|
|
|
|
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]}'
|
|
|
|
|
})
|
2025-08-05 01:23:07 +03:00
|
|
|
|
|
2025-08-08 05:46:36 +03:00
|
|
|
|
elif isinstance(real_server, XrayServerV2):
|
2025-08-05 01:23:07 +03:00
|
|
|
|
try:
|
2025-08-08 05:46:36 +03:00
|
|
|
|
logger.info(f"Checking Xray v2 server: {server.name}")
|
|
|
|
|
# Get server status from new Xray implementation
|
2025-08-05 01:23:07 +03:00
|
|
|
|
status = real_server.get_server_status()
|
|
|
|
|
if status and isinstance(status, dict):
|
2025-08-08 05:46:36 +03:00
|
|
|
|
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")})'
|
2025-08-05 01:23:07 +03:00
|
|
|
|
|
2025-08-08 05:46:36 +03:00
|
|
|
|
message += f', Inbounds: {status.get("total_inbounds", 0)}'
|
|
|
|
|
|
|
|
|
|
logger.info(f"Xray v2 server {server.name} status: {message}")
|
2025-08-05 01:23:07 +03:00
|
|
|
|
return JsonResponse({
|
|
|
|
|
'success': True,
|
|
|
|
|
'status': 'online',
|
|
|
|
|
'message': message
|
|
|
|
|
})
|
|
|
|
|
else:
|
2025-08-08 05:46:36 +03:00
|
|
|
|
error_msg = status.get('error') or status.get('api_error', 'Unknown error')
|
|
|
|
|
logger.warning(f"Xray v2 server {server.name} not accessible: {error_msg}")
|
2025-08-05 01:23:07 +03:00
|
|
|
|
return JsonResponse({
|
|
|
|
|
'success': True,
|
|
|
|
|
'status': 'offline',
|
2025-08-08 05:46:36 +03:00
|
|
|
|
'message': f'❌ Server not accessible: {error_msg}'
|
2025-08-05 01:23:07 +03:00
|
|
|
|
})
|
|
|
|
|
else:
|
2025-08-08 05:46:36 +03:00
|
|
|
|
logger.warning(f"Xray v2 server {server.name} returned invalid status")
|
2025-08-05 01:23:07 +03:00
|
|
|
|
return JsonResponse({
|
|
|
|
|
'success': True,
|
|
|
|
|
'status': 'offline',
|
2025-08-08 05:46:36 +03:00
|
|
|
|
'message': 'Invalid server response'
|
2025-08-05 01:23:07 +03:00
|
|
|
|
})
|
|
|
|
|
except Exception as e:
|
2025-08-08 05:46:36 +03:00
|
|
|
|
logger.error(f"Error checking Xray v2 server {server.name}: {e}")
|
2025-08-05 01:23:07 +03:00
|
|
|
|
return JsonResponse({
|
|
|
|
|
'success': True,
|
|
|
|
|
'status': 'error',
|
|
|
|
|
'message': f'Connection error: {str(e)[:100]}'
|
|
|
|
|
})
|
|
|
|
|
|
2025-07-21 18:55:59 +03:00
|
|
|
|
else:
|
2025-08-05 01:23:07 +03:00
|
|
|
|
# For other server types, just return basic info
|
|
|
|
|
logger.info(f"Server {server.name}, type: {server.server_type}")
|
2025-07-21 18:55:59 +03:00
|
|
|
|
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)
|
2024-10-20 21:57:12 +00:00
|
|
|
|
|
2025-07-21 03:32:37 +03:00
|
|
|
|
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:
|
2025-07-21 04:18:27 +03:00
|
|
|
|
# Get the real polymorphic instance
|
|
|
|
|
real_server = server.get_real_instance()
|
|
|
|
|
server_type = type(real_server).__name__
|
|
|
|
|
|
|
|
|
|
# Check if this is an Outline server
|
2025-07-21 03:32:37 +03:00
|
|
|
|
from vpn.server_plugins.outline import OutlineServer
|
|
|
|
|
|
2025-07-21 04:18:27 +03:00
|
|
|
|
if isinstance(real_server, OutlineServer) and hasattr(real_server, 'client'):
|
2025-07-21 03:32:37 +03:00
|
|
|
|
# For Outline servers, get all keys and delete them
|
|
|
|
|
try:
|
2025-07-21 04:18:27 +03:00
|
|
|
|
keys = real_server.client.get_keys()
|
2025-07-21 03:32:37 +03:00
|
|
|
|
keys_count = len(keys)
|
|
|
|
|
|
|
|
|
|
for key in keys:
|
|
|
|
|
try:
|
2025-07-21 04:18:27 +03:00
|
|
|
|
real_server.client.delete_key(key.key_id)
|
2025-07-21 03:32:37 +03:00
|
|
|
|
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)"
|
2025-07-21 17:15:35 +03:00
|
|
|
|
|
|
|
|
|
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_all_users_on_server
|
|
|
|
|
|
|
|
|
|
tasks_started = 0
|
|
|
|
|
errors = []
|
|
|
|
|
|
|
|
|
|
for server in queryset:
|
|
|
|
|
try:
|
|
|
|
|
task = sync_all_users_on_server.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"
|
2025-08-08 05:46:36 +03:00
|
|
|
|
|
|
|
|
|
def check_status(self, request, queryset):
|
|
|
|
|
"""Check status for selected servers"""
|
|
|
|
|
for server in queryset:
|
|
|
|
|
try:
|
|
|
|
|
status = server.get_server_status()
|
|
|
|
|
msg = f"{server.name}: {status.get('accessible', 'Unknown')} - {status.get('status', 'N/A')}"
|
|
|
|
|
self.message_user(request, msg, level=messages.INFO)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
self.message_user(request, f"Error checking {server.name}: {e}", level=messages.ERROR)
|
|
|
|
|
check_status.short_description = "📊 Check server status"
|
|
|
|
|
|
|
|
|
|
def sync_xray_inbounds(self, request, queryset):
|
|
|
|
|
"""Sync inbounds for selected servers (Xray v2 only)"""
|
|
|
|
|
from .server_plugins.xray_v2 import XrayServerV2
|
|
|
|
|
synced_count = 0
|
|
|
|
|
|
|
|
|
|
for server in queryset:
|
|
|
|
|
try:
|
|
|
|
|
real_server = server.get_real_instance()
|
|
|
|
|
if isinstance(real_server, XrayServerV2):
|
|
|
|
|
real_server.sync_inbounds()
|
|
|
|
|
synced_count += 1
|
|
|
|
|
self.message_user(request, f"Scheduled inbound sync for {server.name}", level=messages.SUCCESS)
|
|
|
|
|
else:
|
|
|
|
|
self.message_user(request, f"{server.name} is not an Xray v2 server", level=messages.WARNING)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
self.message_user(request, f"Error syncing inbounds for {server.name}: {e}", level=messages.ERROR)
|
|
|
|
|
|
|
|
|
|
if synced_count > 0:
|
|
|
|
|
self.message_user(request, f"Scheduled inbound sync for {synced_count} server(s)", level=messages.SUCCESS)
|
|
|
|
|
sync_xray_inbounds.short_description = "🔧 Sync Xray inbounds"
|
2025-07-21 03:32:37 +03:00
|
|
|
|
|
2025-07-21 17:15:35 +03:00
|
|
|
|
@admin.display(description='Server', ordering='name')
|
|
|
|
|
def name_with_icon(self, obj):
|
|
|
|
|
"""Display server name with type icon"""
|
|
|
|
|
icons = {
|
|
|
|
|
'outline': '🔵',
|
|
|
|
|
'wireguard': '🟢',
|
2025-07-27 20:37:21 +03:00
|
|
|
|
'xray_core': '🟣',
|
2025-08-08 05:46:36 +03:00
|
|
|
|
'xray_v2': '🟡',
|
2025-07-21 17:15:35 +03:00
|
|
|
|
}
|
|
|
|
|
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):
|
2025-07-21 18:26:29 +03:00
|
|
|
|
"""Display user count and active links statistics (optimized)"""
|
2025-07-21 17:15:35 +03:00
|
|
|
|
try:
|
|
|
|
|
from django.utils import timezone
|
|
|
|
|
from datetime import timedelta
|
|
|
|
|
|
|
|
|
|
user_count = obj.user_count if hasattr(obj, 'user_count') else 0
|
|
|
|
|
|
2025-07-21 18:26:29 +03:00
|
|
|
|
# Use prefetched data if available
|
|
|
|
|
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()
|
2025-07-21 17:15:35 +03:00
|
|
|
|
|
|
|
|
|
# Color coding based on activity
|
|
|
|
|
if user_count == 0:
|
|
|
|
|
color = '#9ca3af' # gray - no users
|
|
|
|
|
elif total_links == 0:
|
|
|
|
|
color = '#dc2626' # red - no links
|
2025-07-21 18:26:29 +03:00
|
|
|
|
elif total_links > 0 and active_links > total_links * 0.7: # High activity
|
2025-07-21 17:15:35 +03:00
|
|
|
|
color = '#16a34a' # green
|
2025-07-21 18:26:29 +03:00
|
|
|
|
elif total_links > 0 and active_links > total_links * 0.3: # Medium activity
|
2025-07-21 17:15:35 +03:00
|
|
|
|
color = '#eab308' # yellow
|
|
|
|
|
else:
|
|
|
|
|
color = '#f97316' # orange - low activity
|
|
|
|
|
|
|
|
|
|
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:
|
|
|
|
|
return mark_safe(f'<span style="color: #dc2626; font-size: 11px;">Stats error: {e}</span>')
|
|
|
|
|
|
|
|
|
|
@admin.display(description='Activity')
|
|
|
|
|
def activity_summary(self, obj):
|
2025-07-21 18:26:29 +03:00
|
|
|
|
"""Display recent activity summary (optimized)"""
|
2025-07-21 17:15:35 +03:00
|
|
|
|
try:
|
2025-07-21 18:26:29 +03:00
|
|
|
|
# Simplified version - avoid heavy DB queries on list page
|
|
|
|
|
# This could be computed once per page load if needed
|
2025-07-21 17:15:35 +03:00
|
|
|
|
return mark_safe(
|
2025-07-21 18:26:29 +03:00
|
|
|
|
f'<div style="font-size: 11px; color: #6b7280;">' +
|
|
|
|
|
f'<div>📊 Activity data</div>' +
|
|
|
|
|
f'<div><small>Click to view details</small></div>' +
|
2025-07-21 17:15:35 +03:00
|
|
|
|
f'</div>'
|
|
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return mark_safe(f'<span style="color: #dc2626; font-size: 11px;">Activity unavailable</span>')
|
|
|
|
|
|
2024-10-20 21:57:12 +00:00
|
|
|
|
@admin.display(description='Status')
|
2025-07-21 17:15:35 +03:00
|
|
|
|
def server_status_compact(self, obj):
|
2025-07-21 18:26:29 +03:00
|
|
|
|
"""Display server status in compact format (optimized)"""
|
2025-06-27 16:20:31 +03:00
|
|
|
|
try:
|
2025-07-21 18:26:29 +03:00
|
|
|
|
# Avoid expensive server connectivity checks on list page
|
|
|
|
|
# Show basic info and let users click to check status
|
|
|
|
|
server_type_icons = {
|
|
|
|
|
'outline': '🔵',
|
|
|
|
|
'wireguard': '🟢',
|
2025-07-27 20:37:21 +03:00
|
|
|
|
'xray_core': '🟣',
|
2025-07-21 18:26:29 +03:00
|
|
|
|
}
|
|
|
|
|
icon = server_type_icons.get(obj.server_type, '⚪')
|
2025-07-21 17:15:35 +03:00
|
|
|
|
|
|
|
|
|
return mark_safe(
|
2025-07-21 18:26:29 +03:00
|
|
|
|
f'<div style="color: #6b7280; font-size: 11px;">' +
|
|
|
|
|
f'{icon} {obj.server_type.title()}<br>' +
|
2025-07-21 18:55:59 +03:00
|
|
|
|
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>' +
|
2025-07-21 17:15:35 +03:00
|
|
|
|
f'</div>'
|
|
|
|
|
)
|
2025-06-27 16:20:31 +03:00
|
|
|
|
except Exception as e:
|
2025-07-21 17:15:35 +03:00
|
|
|
|
return mark_safe(
|
|
|
|
|
f'<div style="color: #f97316; font-size: 11px; font-weight: bold;">' +
|
2025-07-21 18:26:29 +03:00
|
|
|
|
f'⚠️ Error<br>' +
|
2025-07-21 17:15:35 +03:00
|
|
|
|
f'<span style="font-weight: normal;" title="{str(e)}">' +
|
|
|
|
|
f'{str(e)[:25]}...</span>' +
|
|
|
|
|
f'</div>'
|
|
|
|
|
)
|
2024-10-20 21:57:12 +00:00
|
|
|
|
|
|
|
|
|
def get_queryset(self, request):
|
|
|
|
|
qs = super().get_queryset(request)
|
|
|
|
|
qs = qs.annotate(user_count=Count('acl'))
|
2025-07-21 18:26:29 +03:00
|
|
|
|
qs = qs.prefetch_related(
|
|
|
|
|
'acl_set__links',
|
|
|
|
|
'acl_set__user'
|
|
|
|
|
)
|
2024-10-20 21:57:12 +00:00
|
|
|
|
return qs
|
2025-08-05 01:23:07 +03:00
|
|
|
|
|
|
|
|
|
def sync_server_view(self, request, object_id):
|
|
|
|
|
"""Dispatch sync to appropriate server type."""
|
|
|
|
|
from django.shortcuts import redirect, get_object_or_404
|
|
|
|
|
from django.contrib import messages
|
2025-08-08 05:46:36 +03:00
|
|
|
|
# XrayCoreServer removed - using XrayServerV2 now
|
2025-08-05 01:23:07 +03:00
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
server = get_object_or_404(Server, pk=object_id)
|
|
|
|
|
real_server = server.get_real_instance()
|
|
|
|
|
|
2025-08-08 05:46:36 +03:00
|
|
|
|
# Handle XrayServerV2
|
|
|
|
|
if isinstance(real_server, XrayServerV2):
|
|
|
|
|
return redirect(f'/admin/vpn/xrayserverv2/{real_server.pk}/sync/')
|
2025-08-05 01:23:07 +03:00
|
|
|
|
|
|
|
|
|
# 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')
|
2024-10-20 21:57:12 +00:00
|
|
|
|
|
2024-10-27 01:06:37 +00:00
|
|
|
|
#admin.site.register(User, UserAdmin)
|
2025-08-08 12:41:33 +03:00
|
|
|
|
# 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
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2024-10-20 21:57:12 +00:00
|
|
|
|
@admin.register(User)
|
|
|
|
|
class UserAdmin(admin.ModelAdmin):
|
|
|
|
|
form = UserForm
|
2025-03-04 17:18:51 +00:00
|
|
|
|
list_display = ('username', 'comment', 'registration_date', 'hash_link', 'server_count')
|
2024-10-27 01:06:37 +00:00
|
|
|
|
search_fields = ('username', 'hash')
|
2025-08-08 12:41:33 +03:00
|
|
|
|
readonly_fields = ('hash_link', 'vpn_access_summary', 'user_statistics_summary')
|
|
|
|
|
inlines = [] # All VPN access info is now in vpn_access_summary
|
2025-07-21 18:26:29 +03:00
|
|
|
|
|
|
|
|
|
class Media:
|
|
|
|
|
css = {
|
2025-07-21 18:55:59 +03:00
|
|
|
|
'all': ('admin/css/vpn_admin.css',)
|
2025-07-21 18:26:29 +03:00
|
|
|
|
}
|
2025-07-21 15:30:57 +03:00
|
|
|
|
|
|
|
|
|
fieldsets = (
|
|
|
|
|
('User Information', {
|
|
|
|
|
'fields': ('username', 'first_name', 'last_name', 'email', 'comment')
|
|
|
|
|
}),
|
|
|
|
|
('Access Information', {
|
2025-08-08 12:41:33 +03:00
|
|
|
|
'fields': ('hash_link', 'is_active', 'vpn_access_summary')
|
2025-07-21 15:30:57 +03:00
|
|
|
|
}),
|
2025-07-21 18:26:29 +03:00
|
|
|
|
('Statistics & Server Management', {
|
|
|
|
|
'fields': ('user_statistics_summary',),
|
|
|
|
|
'classes': ('wide',)
|
2025-07-21 15:30:57 +03:00
|
|
|
|
}),
|
|
|
|
|
)
|
2024-10-20 21:57:12 +00:00
|
|
|
|
|
2025-08-08 12:41:33 +03:00
|
|
|
|
@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>'
|
|
|
|
|
else:
|
|
|
|
|
html += '<p style="margin: 5px 0; color: #6c757d;">No Xray subscriptions</p>'
|
|
|
|
|
html += '</div>'
|
|
|
|
|
|
|
|
|
|
html += '</div>'
|
|
|
|
|
|
|
|
|
|
return format_html(html)
|
|
|
|
|
|
2025-07-20 23:04:58 +03:00
|
|
|
|
@admin.display(description='User Portal', ordering='hash')
|
2025-03-04 17:18:51 +00:00
|
|
|
|
def hash_link(self, obj):
|
2025-07-20 23:04:58 +03:00
|
|
|
|
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
|
|
|
|
|
)
|
2025-07-21 15:30:57 +03:00
|
|
|
|
|
|
|
|
|
@admin.display(description='User Statistics Summary')
|
|
|
|
|
def user_statistics_summary(self, obj):
|
2025-07-21 17:15:35 +03:00
|
|
|
|
"""Display user statistics with integrated server management"""
|
2025-07-21 15:30:57 +03:00
|
|
|
|
try:
|
|
|
|
|
from .models import UserStatistics
|
|
|
|
|
from django.db import models
|
|
|
|
|
|
|
|
|
|
# Get statistics for this user
|
|
|
|
|
user_stats = UserStatistics.objects.filter(user=obj).aggregate(
|
|
|
|
|
total_connections=models.Sum('total_connections'),
|
|
|
|
|
recent_connections=models.Sum('recent_connections'),
|
|
|
|
|
total_links=models.Count('id'),
|
|
|
|
|
max_daily_peak=models.Max('max_daily')
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Get server breakdown
|
|
|
|
|
server_breakdown = UserStatistics.objects.filter(user=obj).values('server_name').annotate(
|
|
|
|
|
connections=models.Sum('total_connections'),
|
|
|
|
|
links=models.Count('id')
|
|
|
|
|
).order_by('-connections')
|
|
|
|
|
|
2025-07-21 17:15:35 +03:00
|
|
|
|
# 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;">'
|
2025-07-21 15:30:57 +03:00
|
|
|
|
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>'
|
2025-07-21 17:15:35 +03:00
|
|
|
|
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())
|
|
|
|
|
|
2025-07-21 18:26:29 +03:00
|
|
|
|
# Server header (no slow server status checks)
|
2025-08-08 12:41:33 +03:00
|
|
|
|
# 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
|
|
|
|
|
|
2025-07-21 17:15:35 +03:00
|
|
|
|
html += f'<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">'
|
2025-08-08 12:41:33 +03:00
|
|
|
|
html += f'<h5 style="margin: 0; color: #333; font-size: 14px; font-weight: 600;">{type_icon} {server.name} ({type_label})</h5>'
|
2025-07-21 17:15:35 +03:00
|
|
|
|
|
|
|
|
|
# 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>'
|
|
|
|
|
|
2025-07-21 18:26:29 +03:00
|
|
|
|
html += '<div class="server-section">'
|
2025-07-21 17:15:35 +03:00
|
|
|
|
|
|
|
|
|
# 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
|
2025-07-21 15:30:57 +03:00
|
|
|
|
|
2025-07-21 17:15:35 +03:00
|
|
|
|
# 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:
|
2025-08-08 12:41:33 +03:00
|
|
|
|
# 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
|
|
|
|
|
|
2025-07-21 17:15:35 +03:00
|
|
|
|
html += f'<button type="button" class="btn btn-sm btn-outline-info btn-sm-custom add-server-btn" '
|
2025-08-08 12:41:33 +03:00
|
|
|
|
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})'
|
2025-07-21 17:15:35 +03:00
|
|
|
|
html += f'</button>'
|
|
|
|
|
html += '</div></div>'
|
2025-07-21 15:30:57 +03:00
|
|
|
|
|
2025-07-21 17:15:35 +03:00
|
|
|
|
html += '</div>' # End user-management-section
|
2025-07-21 15:30:57 +03:00
|
|
|
|
return mark_safe(html)
|
2025-07-21 17:15:35 +03:00
|
|
|
|
|
2025-07-21 15:30:57 +03:00
|
|
|
|
except Exception as e:
|
2025-07-21 17:15:35 +03:00
|
|
|
|
return mark_safe(f'<span style="color: #dc3545;">Error loading management interface: {e}</span>')
|
2025-07-21 15:30:57 +03:00
|
|
|
|
|
|
|
|
|
@admin.display(description='Recent Activity')
|
|
|
|
|
def recent_activity_display(self, obj):
|
2025-07-21 17:15:35 +03:00
|
|
|
|
"""Display recent activity in compact admin-friendly format"""
|
2025-07-21 15:30:57 +03:00
|
|
|
|
try:
|
|
|
|
|
from datetime import timedelta
|
|
|
|
|
from django.utils import timezone
|
|
|
|
|
|
2025-07-21 17:15:35 +03:00
|
|
|
|
# Get recent access logs for this user (last 7 days, limited)
|
|
|
|
|
seven_days_ago = timezone.now() - timedelta(days=7)
|
2025-07-21 15:30:57 +03:00
|
|
|
|
recent_logs = AccessLog.objects.filter(
|
|
|
|
|
user=obj.username,
|
2025-07-21 17:15:35 +03:00
|
|
|
|
timestamp__gte=seven_days_ago
|
|
|
|
|
).order_by('-timestamp')[:15] # Limit to 15 most recent
|
2025-07-21 15:30:57 +03:00
|
|
|
|
|
|
|
|
|
if not recent_logs:
|
2025-07-21 17:15:35 +03:00
|
|
|
|
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;">'
|
2025-07-21 15:30:57 +03:00
|
|
|
|
|
2025-07-21 17:15:35 +03:00
|
|
|
|
# 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>'
|
2025-07-21 15:30:57 +03:00
|
|
|
|
|
2025-07-21 17:15:35 +03:00
|
|
|
|
# Activity entries
|
|
|
|
|
for i, log in enumerate(recent_logs):
|
|
|
|
|
bg_color = '#ffffff' if i % 2 == 0 else '#f8f9fa'
|
2025-07-21 15:30:57 +03:00
|
|
|
|
local_time = localtime(log.timestamp)
|
|
|
|
|
|
2025-07-21 17:15:35 +03:00
|
|
|
|
# Status icon and color
|
2025-07-21 15:30:57 +03:00
|
|
|
|
if log.action == 'Success':
|
|
|
|
|
icon = '✅'
|
2025-07-21 17:15:35 +03:00
|
|
|
|
status_color = '#28a745'
|
2025-07-21 15:30:57 +03:00
|
|
|
|
elif log.action == 'Failed':
|
|
|
|
|
icon = '❌'
|
2025-07-21 17:15:35 +03:00
|
|
|
|
status_color = '#dc3545'
|
2025-07-21 15:30:57 +03:00
|
|
|
|
else:
|
|
|
|
|
icon = 'ℹ️'
|
2025-07-21 17:15:35 +03:00
|
|
|
|
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>'
|
2025-07-21 15:30:57 +03:00
|
|
|
|
|
2025-07-21 17:15:35 +03:00
|
|
|
|
html += f'</div></div>'
|
2025-07-21 15:30:57 +03:00
|
|
|
|
|
2025-07-21 17:15:35 +03:00
|
|
|
|
# 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'
|
2025-07-21 15:30:57 +03:00
|
|
|
|
html += f'</div>'
|
|
|
|
|
|
|
|
|
|
html += '</div>'
|
|
|
|
|
return mark_safe(html)
|
2025-07-21 17:15:35 +03:00
|
|
|
|
|
2025-07-21 15:30:57 +03:00
|
|
|
|
except Exception as e:
|
2025-07-21 17:15:35 +03:00
|
|
|
|
return mark_safe(f'<span style="color: #dc3545; font-size: 12px;">Error loading activity: {e}</span>')
|
2024-10-20 21:57:12 +00:00
|
|
|
|
|
|
|
|
|
@admin.display(description='Allowed servers', ordering='server_count')
|
|
|
|
|
def server_count(self, obj):
|
|
|
|
|
return obj.server_count
|
2025-07-21 15:30:57 +03:00
|
|
|
|
|
2024-10-20 21:57:12 +00:00
|
|
|
|
def get_queryset(self, request):
|
|
|
|
|
qs = super().get_queryset(request)
|
|
|
|
|
qs = qs.annotate(server_count=Count('acl'))
|
|
|
|
|
return qs
|
2025-07-21 15:30:57 +03:00
|
|
|
|
|
|
|
|
|
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'),
|
|
|
|
|
]
|
|
|
|
|
return custom_urls + urls
|
|
|
|
|
|
|
|
|
|
def add_link_view(self, request, user_id):
|
|
|
|
|
"""AJAX view to add a new link for user on specific server"""
|
|
|
|
|
from django.http import JsonResponse
|
|
|
|
|
|
|
|
|
|
if request.method == 'POST':
|
|
|
|
|
try:
|
|
|
|
|
user = User.objects.get(pk=user_id)
|
|
|
|
|
server_id = request.POST.get('server_id')
|
|
|
|
|
comment = request.POST.get('comment', '')
|
|
|
|
|
|
|
|
|
|
if not server_id:
|
|
|
|
|
return JsonResponse({'error': 'Server ID is required'}, status=400)
|
|
|
|
|
|
|
|
|
|
server = Server.objects.get(pk=server_id)
|
|
|
|
|
acl = ACL.objects.get(user=user, server=server)
|
|
|
|
|
|
|
|
|
|
# Create new link
|
|
|
|
|
new_link = ACLLink.objects.create(
|
|
|
|
|
acl=acl,
|
|
|
|
|
comment=comment,
|
|
|
|
|
link=shortuuid.ShortUUID().random(length=16)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return JsonResponse({
|
|
|
|
|
'success': True,
|
|
|
|
|
'link_id': new_link.id,
|
|
|
|
|
'link': new_link.link,
|
|
|
|
|
'comment': new_link.comment,
|
|
|
|
|
'url': f"{EXTERNAL_ADDRESS}/ss/{new_link.link}#{server.name}"
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return JsonResponse({'error': str(e)}, status=500)
|
|
|
|
|
|
|
|
|
|
return JsonResponse({'error': 'Invalid request method'}, status=405)
|
|
|
|
|
|
|
|
|
|
def delete_link_view(self, request, user_id, link_id):
|
|
|
|
|
"""AJAX view to delete a specific link"""
|
|
|
|
|
from django.http import JsonResponse
|
|
|
|
|
|
|
|
|
|
if request.method == 'POST':
|
|
|
|
|
try:
|
|
|
|
|
user = User.objects.get(pk=user_id)
|
|
|
|
|
link = ACLLink.objects.get(pk=link_id, acl__user=user)
|
|
|
|
|
link.delete()
|
|
|
|
|
|
|
|
|
|
return JsonResponse({'success': True})
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return JsonResponse({'error': str(e)}, status=500)
|
|
|
|
|
|
|
|
|
|
return JsonResponse({'error': 'Invalid request method'}, status=405)
|
|
|
|
|
|
|
|
|
|
def add_server_access_view(self, request, user_id):
|
|
|
|
|
"""AJAX view to add server access for user"""
|
|
|
|
|
from django.http import JsonResponse
|
|
|
|
|
|
|
|
|
|
if request.method == 'POST':
|
|
|
|
|
try:
|
|
|
|
|
user = User.objects.get(pk=user_id)
|
|
|
|
|
server_id = request.POST.get('server_id')
|
|
|
|
|
|
|
|
|
|
if not server_id:
|
|
|
|
|
return JsonResponse({'error': 'Server ID is required'}, status=400)
|
|
|
|
|
|
|
|
|
|
server = Server.objects.get(pk=server_id)
|
|
|
|
|
|
|
|
|
|
# Check if ACL already exists
|
|
|
|
|
if ACL.objects.filter(user=user, server=server).exists():
|
|
|
|
|
return JsonResponse({'error': 'User already has access to this server'}, status=400)
|
|
|
|
|
|
|
|
|
|
# Create new ACL (with default link)
|
|
|
|
|
acl = ACL.objects.create(user=user, server=server)
|
|
|
|
|
|
|
|
|
|
return JsonResponse({
|
|
|
|
|
'success': True,
|
|
|
|
|
'server_name': server.name,
|
|
|
|
|
'server_type': server.server_type,
|
|
|
|
|
'acl_id': acl.id
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return JsonResponse({'error': str(e)}, status=500)
|
|
|
|
|
|
|
|
|
|
return JsonResponse({'error': 'Invalid request method'}, status=405)
|
|
|
|
|
|
|
|
|
|
def change_view(self, request, object_id, form_url='', extra_context=None):
|
2025-07-21 18:26:29 +03:00
|
|
|
|
"""Override change view to add user management data and fix layout"""
|
2025-07-21 15:30:57 +03:00
|
|
|
|
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)
|
2024-10-20 21:57:12 +00:00
|
|
|
|
|
2025-08-08 12:41:33 +03:00
|
|
|
|
# Removed save_model as we no longer manage servers directly through the form
|
|
|
|
|
# Legacy VPN access is now managed through the ACL admin interface
|
|
|
|
|
# Xray access is managed through the UserXraySubscriptionInline
|
2025-06-27 16:02:13 +03:00
|
|
|
|
# Note: get_or_create will use the default save() method which creates default links
|
2024-10-20 21:57:12 +00:00
|
|
|
|
|
2024-10-28 17:15:49 +00:00
|
|
|
|
@admin.register(AccessLog)
|
|
|
|
|
class AccessLogAdmin(admin.ModelAdmin):
|
2025-07-21 13:49:43 +03:00
|
|
|
|
list_display = ('user', 'server', 'acl_link_display', 'action', 'formatted_timestamp')
|
2025-01-10 11:32:30 +00:00
|
|
|
|
list_filter = ('user', 'server', 'action', 'timestamp')
|
2025-07-21 13:49:43 +03:00
|
|
|
|
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 '-'
|
2024-10-28 17:15:49 +00:00
|
|
|
|
|
|
|
|
|
@admin.display(description='Timestamp')
|
|
|
|
|
def formatted_timestamp(self, obj):
|
|
|
|
|
local_time = localtime(obj.timestamp)
|
|
|
|
|
return local_time.strftime('%Y-%m-%d %H:%M:%S %Z')
|
|
|
|
|
|
|
|
|
|
@admin.display(description='Details')
|
|
|
|
|
def formated_data(self, obj):
|
|
|
|
|
return format_object(obj.data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ACLLinkInline(admin.TabularInline):
|
|
|
|
|
model = ACLLink
|
|
|
|
|
extra = 1
|
|
|
|
|
help_text = 'Add or change ACL links'
|
|
|
|
|
verbose_name = 'Dynamic link'
|
|
|
|
|
verbose_name_plural = 'Dynamic links'
|
2025-03-04 17:18:51 +00:00
|
|
|
|
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',)
|
2024-10-20 21:57:12 +00:00
|
|
|
|
|
|
|
|
|
@admin.register(ACL)
|
|
|
|
|
class ACLAdmin(admin.ModelAdmin):
|
2024-10-28 17:15:49 +00:00
|
|
|
|
|
|
|
|
|
list_display = ('user', 'server', 'server_type', 'display_links', 'created_at')
|
|
|
|
|
list_filter = (UserNameFilter, 'server__server_type', ServerNameFilter)
|
2025-06-20 11:30:56 +01:00
|
|
|
|
# Fixed search_fields - removed problematic polymorphic server fields
|
|
|
|
|
search_fields = ('user__username', 'user__comment', 'links__link')
|
2024-10-28 17:15:49 +00:00
|
|
|
|
readonly_fields = ('user_info',)
|
|
|
|
|
inlines = [ACLLinkInline]
|
2024-10-20 21:57:12 +00:00
|
|
|
|
|
|
|
|
|
@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:
|
2025-07-21 18:26:29 +03:00
|
|
|
|
# Use cached statistics instead of direct server requests
|
|
|
|
|
from .models import UserStatistics
|
|
|
|
|
user_stats = UserStatistics.objects.filter(
|
|
|
|
|
user=user,
|
|
|
|
|
server_name=server.name
|
|
|
|
|
).first()
|
|
|
|
|
|
|
|
|
|
if user_stats:
|
|
|
|
|
# Format cached data nicely
|
|
|
|
|
data = {
|
|
|
|
|
'user': user.username,
|
|
|
|
|
'server': server.name,
|
|
|
|
|
'total_connections': user_stats.total_connections,
|
|
|
|
|
'recent_connections': user_stats.recent_connections,
|
|
|
|
|
'max_daily': user_stats.max_daily,
|
|
|
|
|
'last_updated': user_stats.updated_at.strftime('%Y-%m-%d %H:%M:%S'),
|
|
|
|
|
'status': 'from_cache'
|
|
|
|
|
}
|
|
|
|
|
return format_object(data)
|
|
|
|
|
else:
|
|
|
|
|
# Fallback to minimal server check (avoid slow API calls on admin pages)
|
|
|
|
|
return mark_safe(
|
|
|
|
|
'<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>'
|
|
|
|
|
)
|
2024-10-20 21:57:12 +00:00
|
|
|
|
except Exception as e:
|
2025-07-20 22:30:04 +03:00
|
|
|
|
import logging
|
|
|
|
|
logger = logging.getLogger(__name__)
|
2025-07-21 18:26:29 +03:00
|
|
|
|
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>")
|
2024-10-28 17:15:49 +00:00
|
|
|
|
|
2025-07-20 23:04:58 +03:00
|
|
|
|
@admin.display(description='User Links')
|
2024-10-28 17:15:49 +00:00
|
|
|
|
def display_links(self, obj):
|
2025-07-21 18:55:59 +03:00
|
|
|
|
links_count = obj.links.count()
|
2025-07-20 23:04:58 +03:00
|
|
|
|
portal_url = f"{EXTERNAL_ADDRESS}/u/{obj.user.hash}"
|
|
|
|
|
|
|
|
|
|
return format_html(
|
2025-07-21 18:55:59 +03:00
|
|
|
|
'<div style="font-size: 12px; margin-bottom: 8px;">'
|
|
|
|
|
'<strong>🔗 {} link(s)</strong>'
|
|
|
|
|
'</div>'
|
2025-07-20 23:04:58 +03:00
|
|
|
|
'<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>',
|
2025-07-21 18:55:59 +03:00
|
|
|
|
links_count, portal_url
|
2025-07-20 23:04:58 +03:00
|
|
|
|
)
|
2025-06-20 11:35:29 +01:00
|
|
|
|
|
2025-07-21 03:32:37 +03:00
|
|
|
|
|
2025-07-21 14:40:52 +03:00
|
|
|
|
# Note: UserStatistics is not registered separately as admin model.
|
|
|
|
|
# All user statistics functionality is integrated into ACLLinkAdmin below.
|
2025-07-21 03:32:37 +03:00
|
|
|
|
@admin.register(ACLLink)
|
|
|
|
|
class ACLLinkAdmin(admin.ModelAdmin):
|
2025-07-21 14:40:52 +03:00
|
|
|
|
list_display = ('link_display', 'user_display', 'server_display', 'comment_display', 'stats_display', 'usage_chart_display', 'last_access_display', 'created_display')
|
2025-07-21 03:32:37 +03:00
|
|
|
|
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
|
2025-07-21 14:40:52 +03:00
|
|
|
|
actions = ['delete_selected_links', 'update_statistics_action']
|
2025-07-21 03:32:37 +03:00
|
|
|
|
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': '🟢',
|
2025-07-27 20:37:21 +03:00
|
|
|
|
'xray_core': '🟣',
|
2025-07-21 03:32:37 +03:00
|
|
|
|
}
|
|
|
|
|
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 '-'
|
|
|
|
|
|
2025-07-21 14:40:52 +03:00
|
|
|
|
@admin.display(description='Statistics')
|
|
|
|
|
def stats_display(self, obj):
|
|
|
|
|
try:
|
|
|
|
|
from .models import UserStatistics
|
|
|
|
|
stats = UserStatistics.objects.get(
|
|
|
|
|
user=obj.acl.user,
|
|
|
|
|
server_name=obj.acl.server.name,
|
|
|
|
|
acl_link_id=obj.link
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Color coding based on usage
|
|
|
|
|
if stats.total_connections > 100:
|
|
|
|
|
color = '#16a34a' # green - high usage
|
|
|
|
|
elif stats.total_connections > 10:
|
|
|
|
|
color = '#eab308' # yellow - medium usage
|
|
|
|
|
elif stats.total_connections > 0:
|
|
|
|
|
color = '#f97316' # orange - low usage
|
|
|
|
|
else:
|
|
|
|
|
color = '#9ca3af' # gray - no usage
|
|
|
|
|
|
|
|
|
|
return mark_safe(
|
|
|
|
|
f'<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 .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>')
|
|
|
|
|
|
2025-07-21 17:40:03 +03:00
|
|
|
|
# Create wider mini chart for better visibility
|
2025-07-21 14:40:52 +03:00
|
|
|
|
max_val = max(stats.daily_usage) if stats.daily_usage else 1
|
2025-07-21 17:40:03 +03:00
|
|
|
|
chart_html = '<div style="display: flex; align-items: end; gap: 1px; height: 35px; width: 180px;">'
|
2025-07-21 14:40:52 +03:00
|
|
|
|
|
2025-07-21 17:40:03 +03:00
|
|
|
|
# Show last 30 days with wider bars for better visibility
|
|
|
|
|
for day_count in stats.daily_usage[-30:]: # Last 30 days
|
2025-07-21 14:40:52 +03:00
|
|
|
|
if max_val > 0:
|
|
|
|
|
height_percent = (day_count / max_val) * 100
|
|
|
|
|
else:
|
|
|
|
|
height_percent = 0
|
|
|
|
|
|
|
|
|
|
color = '#4ade80' if day_count > 0 else '#e5e7eb'
|
2025-07-21 17:40:03 +03:00
|
|
|
|
chart_html += f'<div style="background: {color}; width: 5px; height: {height_percent}%; min-height: 1px; border-radius: 1px;" title="{day_count} connections"></div>'
|
2025-07-21 14:40:52 +03:00
|
|
|
|
|
|
|
|
|
chart_html += '</div>'
|
2025-07-21 17:40:03 +03:00
|
|
|
|
|
|
|
|
|
# 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>'
|
|
|
|
|
|
2025-07-21 14:40:52 +03:00
|
|
|
|
return mark_safe(chart_html)
|
|
|
|
|
except:
|
|
|
|
|
return mark_safe('<span style="color: #dc2626; font-size: 11px;">-</span>')
|
|
|
|
|
|
2025-07-21 12:12:31 +03:00
|
|
|
|
@admin.display(description='Last Access', ordering='last_access_time')
|
2025-07-21 03:32:37 +03:00
|
|
|
|
def last_access_display(self, obj):
|
2025-07-21 12:12:31 +03:00
|
|
|
|
if obj.last_access_time:
|
2025-07-21 03:32:37 +03:00
|
|
|
|
from django.utils import timezone
|
|
|
|
|
from datetime import timedelta
|
|
|
|
|
|
2025-07-21 12:12:31 +03:00
|
|
|
|
local_time = localtime(obj.last_access_time)
|
2025-07-21 03:32:37 +03:00
|
|
|
|
now = timezone.now()
|
2025-07-21 12:12:31 +03:00
|
|
|
|
diff = now - obj.last_access_time
|
2025-07-21 03:32:37 +03:00
|
|
|
|
|
|
|
|
|
# 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(
|
2025-07-21 14:40:52 +03:00
|
|
|
|
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>'
|
2025-07-21 03:32:37 +03:00
|
|
|
|
)
|
2025-07-21 14:40:52 +03:00
|
|
|
|
return mark_safe('<span style="color: #dc2626; font-weight: bold; font-size: 12px;">Never</span>')
|
2025-07-21 03:32:37 +03:00
|
|
|
|
|
|
|
|
|
@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"
|
2025-07-21 14:40:52 +03:00
|
|
|
|
|
|
|
|
|
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"
|
2025-07-21 03:32:37 +03:00
|
|
|
|
|
|
|
|
|
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):
|
2025-07-21 14:40:52 +03:00
|
|
|
|
# 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
|
2025-07-21 03:32:37 +03:00
|
|
|
|
extra_context = extra_context or {}
|
|
|
|
|
|
2025-07-21 12:12:31 +03:00
|
|
|
|
# Get queryset for statistics
|
2025-07-21 03:32:37 +03:00
|
|
|
|
queryset = self.get_queryset(request)
|
|
|
|
|
|
|
|
|
|
total_links = queryset.count()
|
2025-07-21 12:12:31 +03:00
|
|
|
|
never_accessed = queryset.filter(last_access_time__isnull=True).count()
|
2025-07-21 03:32:37 +03:00
|
|
|
|
|
|
|
|
|
from django.utils import timezone
|
|
|
|
|
from datetime import timedelta
|
2025-07-21 14:40:52 +03:00
|
|
|
|
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
|
2025-07-21 03:32:37 +03:00
|
|
|
|
old_links = queryset.filter(
|
2025-07-21 12:12:31 +03:00
|
|
|
|
Q(last_access_time__lt=three_months_ago) | Q(last_access_time__isnull=True)
|
2025-07-21 03:32:37 +03:00
|
|
|
|
).count()
|
|
|
|
|
|
2025-07-21 14:40:52 +03:00
|
|
|
|
recent_week = queryset.filter(last_access_time__gte=one_week_ago).count()
|
|
|
|
|
recent_month = queryset.filter(last_access_time__gte=one_month_ago).count()
|
|
|
|
|
|
|
|
|
|
# Calculate comprehensive statistics from cache
|
|
|
|
|
try:
|
|
|
|
|
from .models import UserStatistics
|
|
|
|
|
from django.db import models
|
|
|
|
|
|
|
|
|
|
# Total usage statistics
|
|
|
|
|
cached_stats = UserStatistics.objects.aggregate(
|
|
|
|
|
total_uses=models.Sum('total_connections'),
|
|
|
|
|
recent_uses=models.Sum('recent_connections'),
|
|
|
|
|
max_daily_peak=models.Max('max_daily')
|
|
|
|
|
)
|
|
|
|
|
total_uses = cached_stats['total_uses'] or 0
|
|
|
|
|
recent_uses = cached_stats['recent_uses'] or 0
|
|
|
|
|
max_daily_peak = cached_stats['max_daily_peak'] or 0
|
|
|
|
|
|
|
|
|
|
# Server and user breakdown
|
|
|
|
|
server_stats = UserStatistics.objects.values('server_name').annotate(
|
|
|
|
|
total_connections=models.Sum('total_connections'),
|
|
|
|
|
link_count=models.Count('id')
|
|
|
|
|
).order_by('-total_connections')[:5] # Top 5 servers
|
|
|
|
|
|
|
|
|
|
user_stats = UserStatistics.objects.values('user__username').annotate(
|
|
|
|
|
total_connections=models.Sum('total_connections'),
|
|
|
|
|
link_count=models.Count('id')
|
|
|
|
|
).order_by('-total_connections')[:5] # Top 5 users
|
|
|
|
|
|
|
|
|
|
# Links with cache data count
|
|
|
|
|
cached_links_count = UserStatistics.objects.filter(
|
|
|
|
|
acl_link_id__isnull=False
|
|
|
|
|
).count()
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
total_uses = 0
|
|
|
|
|
recent_uses = 0
|
|
|
|
|
max_daily_peak = 0
|
|
|
|
|
server_stats = []
|
|
|
|
|
user_stats = []
|
|
|
|
|
cached_links_count = 0
|
|
|
|
|
|
|
|
|
|
# Active vs inactive breakdown
|
|
|
|
|
active_links = total_links - never_accessed - old_links
|
|
|
|
|
if active_links < 0:
|
|
|
|
|
active_links = 0
|
|
|
|
|
|
2025-07-21 03:32:37 +03:00
|
|
|
|
extra_context.update({
|
|
|
|
|
'total_links': total_links,
|
|
|
|
|
'never_accessed': never_accessed,
|
|
|
|
|
'old_links': old_links,
|
2025-07-21 14:40:52 +03:00
|
|
|
|
'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,
|
2025-07-21 03:32:37 +03:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return super().changelist_view(request, extra_context)
|
|
|
|
|
|
|
|
|
|
def get_ordering(self, request):
|
|
|
|
|
"""Allow sorting by annotated fields"""
|
2025-07-21 12:12:31 +03:00
|
|
|
|
# Handle sorting by last_access_time if requested
|
2025-07-21 03:32:37 +03:00
|
|
|
|
order_var = request.GET.get('o')
|
|
|
|
|
if order_var:
|
|
|
|
|
try:
|
|
|
|
|
field_index = int(order_var.lstrip('-'))
|
2025-07-21 14:40:52 +03:00
|
|
|
|
# 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
|
2025-07-21 03:32:37 +03:00
|
|
|
|
if order_var.startswith('-'):
|
2025-07-21 12:12:31 +03:00
|
|
|
|
return ['-last_access_time']
|
2025-07-21 03:32:37 +03:00
|
|
|
|
else:
|
2025-07-21 12:12:31 +03:00
|
|
|
|
return ['last_access_time']
|
2025-07-21 03:32:37 +03:00
|
|
|
|
except (ValueError, IndexError):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
# Default ordering
|
|
|
|
|
return ['-acl__created_at', 'acl__user__username']
|
|
|
|
|
|
|
|
|
|
|
2025-06-20 11:35:29 +01:00
|
|
|
|
try:
|
2025-07-20 22:50:22 +03:00
|
|
|
|
from django_celery_results.models import GroupResult, TaskResult
|
2025-06-20 11:35:29 +01:00
|
|
|
|
from django_celery_beat.models import (
|
|
|
|
|
PeriodicTask,
|
|
|
|
|
ClockedSchedule,
|
|
|
|
|
CrontabSchedule,
|
|
|
|
|
IntervalSchedule,
|
|
|
|
|
SolarSchedule
|
|
|
|
|
)
|
2025-07-20 22:55:07 +03:00
|
|
|
|
from django.contrib.auth.models import Group
|
2025-06-20 11:35:29 +01:00
|
|
|
|
|
2025-07-20 22:55:07 +03:00
|
|
|
|
# Unregister celery models that we don't want in admin
|
2025-06-20 11:35:29 +01:00
|
|
|
|
admin.site.unregister(GroupResult)
|
|
|
|
|
admin.site.unregister(PeriodicTask)
|
|
|
|
|
admin.site.unregister(ClockedSchedule)
|
|
|
|
|
admin.site.unregister(CrontabSchedule)
|
|
|
|
|
admin.site.unregister(IntervalSchedule)
|
|
|
|
|
admin.site.unregister(SolarSchedule)
|
2025-07-20 22:50:22 +03:00
|
|
|
|
admin.site.unregister(TaskResult)
|
2025-06-20 11:35:29 +01:00
|
|
|
|
|
2025-07-20 22:55:07 +03:00
|
|
|
|
# Unregister Django's default Group model
|
|
|
|
|
admin.site.unregister(Group)
|
|
|
|
|
|
2025-06-20 11:35:29 +01:00
|
|
|
|
except (ImportError, admin.sites.NotRegistered):
|
|
|
|
|
pass
|
2025-07-20 22:50:22 +03:00
|
|
|
|
|
|
|
|
|
# Custom Celery admin interfaces
|
|
|
|
|
try:
|
|
|
|
|
from django_celery_results.models import TaskResult
|
|
|
|
|
|
|
|
|
|
@admin.register(TaskResult)
|
|
|
|
|
class CustomTaskResultAdmin(admin.ModelAdmin):
|
|
|
|
|
list_display = ('task_name_display', 'status', 'date_created', 'date_done', 'worker', 'result_display', 'traceback_display')
|
|
|
|
|
list_filter = ('status', 'date_created', 'worker', 'task_name')
|
|
|
|
|
search_fields = ('task_name', 'task_id', 'worker')
|
|
|
|
|
readonly_fields = ('task_id', 'task_name', 'status', 'result_formatted', 'date_created', 'date_done', 'traceback', 'worker', 'task_args', 'task_kwargs', 'meta')
|
|
|
|
|
ordering = ('-date_created',)
|
|
|
|
|
list_per_page = 50
|
|
|
|
|
|
|
|
|
|
fieldsets = (
|
|
|
|
|
('Task Information', {
|
|
|
|
|
'fields': ('task_id', 'task_name', 'status', 'worker')
|
|
|
|
|
}),
|
|
|
|
|
('Timing', {
|
|
|
|
|
'fields': ('date_created', 'date_done')
|
|
|
|
|
}),
|
|
|
|
|
('Result', {
|
|
|
|
|
'fields': ('result_formatted',),
|
|
|
|
|
'classes': ('collapse',)
|
|
|
|
|
}),
|
|
|
|
|
('Arguments', {
|
|
|
|
|
'fields': ('task_args', 'task_kwargs'),
|
|
|
|
|
'classes': ('collapse',)
|
|
|
|
|
}),
|
|
|
|
|
('Error Details', {
|
|
|
|
|
'fields': ('traceback',),
|
|
|
|
|
'classes': ('collapse',)
|
|
|
|
|
}),
|
|
|
|
|
('Metadata', {
|
|
|
|
|
'fields': ('meta',),
|
|
|
|
|
'classes': ('collapse',)
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@admin.display(description='Task Name', ordering='task_name')
|
|
|
|
|
def task_name_display(self, obj):
|
|
|
|
|
task_names = {
|
|
|
|
|
'sync_all_servers': '🔄 Sync All Servers',
|
|
|
|
|
'sync_all_users_on_server': '👥 Sync Users on Server',
|
|
|
|
|
'sync_server_info': '⚙️ Sync Server Info',
|
|
|
|
|
'sync_user_on_server': '👤 Sync User on Server',
|
|
|
|
|
'cleanup_task_logs': '🧹 Cleanup Old Logs',
|
2025-07-21 13:23:10 +03:00
|
|
|
|
'update_user_statistics': '📊 Update Statistics',
|
2025-07-20 22:50:22 +03:00
|
|
|
|
}
|
|
|
|
|
return task_names.get(obj.task_name, obj.task_name)
|
|
|
|
|
|
|
|
|
|
@admin.display(description='Result')
|
|
|
|
|
def result_display(self, obj):
|
|
|
|
|
if obj.status == 'SUCCESS' and obj.result:
|
|
|
|
|
try:
|
|
|
|
|
import json
|
|
|
|
|
result = json.loads(obj.result) if isinstance(obj.result, str) else obj.result
|
|
|
|
|
if isinstance(result, str):
|
|
|
|
|
return result[:100] + '...' if len(result) > 100 else result
|
|
|
|
|
elif isinstance(result, dict):
|
|
|
|
|
return ', '.join(f'{k}: {v}' for k, v in result.items())[:100]
|
|
|
|
|
except:
|
|
|
|
|
return str(obj.result)[:100] if obj.result else '-'
|
|
|
|
|
elif obj.status == 'FAILURE':
|
|
|
|
|
return '❌ Failed'
|
|
|
|
|
elif obj.status == 'PENDING':
|
|
|
|
|
return '⏳ Pending'
|
|
|
|
|
elif obj.status == 'RETRY':
|
|
|
|
|
return '🔄 Retrying'
|
|
|
|
|
return '-'
|
|
|
|
|
|
|
|
|
|
@admin.display(description='Result Details')
|
|
|
|
|
def result_formatted(self, obj):
|
|
|
|
|
if obj.result:
|
|
|
|
|
try:
|
|
|
|
|
import json
|
|
|
|
|
result = json.loads(obj.result) if isinstance(obj.result, str) else obj.result
|
|
|
|
|
formatted = json.dumps(result, indent=2)
|
|
|
|
|
return mark_safe(f"<pre>{formatted}</pre>")
|
|
|
|
|
except:
|
|
|
|
|
return mark_safe(f"<pre>{obj.result}</pre>")
|
|
|
|
|
return '-'
|
|
|
|
|
|
|
|
|
|
@admin.display(description='Error Info')
|
|
|
|
|
def traceback_display(self, obj):
|
|
|
|
|
if obj.traceback:
|
|
|
|
|
# Show first 200 chars of traceback
|
|
|
|
|
short_tb = obj.traceback[:200] + '...' if len(obj.traceback) > 200 else obj.traceback
|
|
|
|
|
return mark_safe(f"<pre style='color: red; font-size: 12px;'>{short_tb}</pre>")
|
|
|
|
|
return '-'
|
|
|
|
|
|
|
|
|
|
def has_add_permission(self, request):
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
def has_change_permission(self, request, obj=None):
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
except ImportError:
|
|
|
|
|
pass
|
2025-08-08 05:46:36 +03:00
|
|
|
|
|
|
|
|
|
# Register XrayServerV2 admin
|
|
|
|
|
admin.site.register(XrayServerV2, XrayServerV2Admin)
|
|
|
|
|
|
|
|
|
|
# Add subscription management to User admin
|
|
|
|
|
from django.contrib.admin import site
|
|
|
|
|
for model, admin_instance in site._registry.items():
|
|
|
|
|
if model.__name__ == 'User' and hasattr(admin_instance, 'fieldsets'):
|
|
|
|
|
add_subscription_management_to_user(admin_instance.__class__)
|
|
|
|
|
break
|