")
except Exception as e:
return mark_safe(f"Error: {e}")
class UserNameFilter(admin.SimpleListFilter):
title = 'User'
parameter_name = 'user'
def lookups(self, request, model_admin):
users = set(User.objects.values_list('username', flat=True))
return [(user, user) for user in users]
def queryset(self, request, queryset):
if self.value():
return queryset.filter(user__username=self.value())
return queryset
class ServerNameFilter(admin.SimpleListFilter):
title = 'Server Name'
parameter_name = 'acl__server__name'
def lookups(self, request, model_admin):
servers = set(ACL.objects.values_list('server__name', flat=True))
return [(server, server) for server in servers]
def queryset(self, request, queryset):
if self.value():
return queryset.filter(acl__server__name=self.value())
return queryset
class LastAccessFilter(admin.SimpleListFilter):
title = 'Last Access'
parameter_name = 'last_access_status'
def lookups(self, request, model_admin):
return [
('never', 'Never accessed'),
('week', 'Last week'),
('month', 'Last month'),
('old', 'Older than 3 months'),
]
def queryset(self, request, queryset):
from django.utils import timezone
from datetime import timedelta
if self.value() == 'never':
# Links that have never been accessed
return queryset.filter(last_access_time__isnull=True)
elif self.value() == 'week':
# Links accessed in the last week
week_ago = timezone.now() - timedelta(days=7)
return queryset.filter(last_access_time__gte=week_ago)
elif self.value() == 'month':
# Links accessed in the last month
month_ago = timezone.now() - timedelta(days=30)
return queryset.filter(last_access_time__gte=month_ago)
elif self.value() == 'old':
# Links not accessed for more than 3 months
three_months_ago = timezone.now() - timedelta(days=90)
return queryset.filter(last_access_time__lt=three_months_ago)
return queryset
@admin.register(Server)
class ServerAdmin(PolymorphicParentModelAdmin):
base_model = Server
child_models = (OutlineServer, WireguardServer)
list_display = ('name_with_icon', 'server_type', 'comment_short', 'user_stats', 'activity_summary', 'server_status_compact', 'registration_date')
search_fields = ('name', 'comment')
list_filter = ('server_type', )
actions = ['move_clients_action', 'purge_all_keys_action', 'sync_all_selected_servers']
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'),
]
return custom_urls + urls
def move_clients_action(self, request, queryset):
"""Кастомное действие для перехода к странице переноса клиентов"""
if queryset.count() == 0:
self.message_user(request, "Выберите хотя бы один сервер.", level=messages.ERROR)
return
# Перенаправляем на страницу переноса клиентов
selected_ids = ','.join(str(server.id) for server in queryset)
return HttpResponseRedirect(f"{reverse('admin:server_move_clients')}?servers={selected_ids}")
move_clients_action.short_description = "Move client links between servers"
def move_clients_view(self, request):
"""View for moving clients between servers"""
if request.method == 'GET':
# Get selected servers from URL parameters
server_ids = request.GET.get('servers', '').split(',')
if not server_ids or server_ids == ['']:
messages.error(request, "No servers selected.")
return redirect('admin:vpn_server_changelist')
try:
# Only work with database objects, don't check server connectivity
servers = Server.objects.filter(id__in=server_ids)
all_servers = Server.objects.all()
# Get ACL links for selected servers with related data
# This is purely database operation, no server connectivity required
links_by_server = {}
for server in servers:
try:
# Get all ACL links for this server with user and ACL data
links = ACLLink.objects.filter(
acl__server=server
).select_related('acl__user', 'acl__server').order_by('acl__user__username', 'comment')
links_by_server[server] = links
except Exception as e:
# Log the error but continue with other servers
messages.warning(request, f"Warning: Could not load links for server {server.name}: {e}")
links_by_server[server] = []
context = {
'title': 'Move Client Links Between Servers',
'servers': servers,
'all_servers': all_servers,
'links_by_server': links_by_server,
}
return render(request, 'admin/move_clients.html', context)
except Exception as e:
messages.error(request, f"Database error while loading data: {e}")
return redirect('admin:vpn_server_changelist')
elif request.method == 'POST':
# Process the transfer of ACL links - purely database operations
try:
source_server_id = request.POST.get('source_server')
target_server_id = request.POST.get('target_server')
selected_link_ids = request.POST.getlist('selected_links')
comment_regex = request.POST.get('comment_regex', '').strip()
if not source_server_id or not target_server_id:
messages.error(request, "Please select both source and target servers.")
return redirect(request.get_full_path())
if source_server_id == target_server_id:
messages.error(request, "Source and target servers cannot be the same.")
return redirect(request.get_full_path())
if not selected_link_ids:
messages.error(request, "Please select at least one link to move.")
return redirect(request.get_full_path())
# Parse and validate regex pattern if provided
regex_pattern = None
regex_replacement = None
regex_parts = None
if comment_regex:
try:
import re
regex_parts = comment_regex.split(' -> ')
if len(regex_parts) != 2:
messages.error(request, "Invalid regex format. Use: pattern -> replacement")
return redirect(request.get_full_path())
pattern_str = regex_parts[0]
replacement_str = regex_parts[1]
# Convert JavaScript-style $1, $2, $3 to Python-style \1, \2, \3
python_replacement = replacement_str
import re as regex_module
# Replace $1, $2, etc. with \1, \2, etc. for Python regex
python_replacement = regex_module.sub(r'\$(\d+)', r'\\\1', replacement_str)
# Test compile the regex pattern
regex_pattern = re.compile(pattern_str)
regex_replacement = python_replacement
# Test the replacement on a sample string to validate syntax
test_result = regex_pattern.sub(regex_replacement, "test sample")
except re.error as e:
messages.error(request, f"Invalid regular expression pattern '{regex_parts[0] if regex_parts else comment_regex}': {e}")
return redirect(request.get_full_path())
except Exception as e:
messages.error(request, f"Error in regex replacement '{replacement_str if 'replacement_str' in locals() else 'unknown'}': {e}")
return redirect(request.get_full_path())
# Get server objects from database only
try:
source_server = Server.objects.get(id=source_server_id)
target_server = Server.objects.get(id=target_server_id)
except Server.DoesNotExist:
messages.error(request, "One of the selected servers was not found in database.")
return redirect('admin:vpn_server_changelist')
moved_count = 0
errors = []
users_processed = set()
comments_transformed = 0
# Process each selected link - database operations only
for link_id in selected_link_ids:
try:
# Get the ACL link with related ACL and user data
acl_link = ACLLink.objects.select_related('acl__user', 'acl__server').get(
id=link_id,
acl__server=source_server
)
user = acl_link.acl.user
# Apply regex transformation to comment if provided
original_comment = acl_link.comment
if regex_pattern and regex_replacement is not None:
try:
# Use Python's re.sub for replacement, which properly handles $1, $2 groups
new_comment = regex_pattern.sub(regex_replacement, original_comment)
if new_comment != original_comment:
acl_link.comment = new_comment
comments_transformed += 1
# Debug logging - shows both original and converted patterns
print(f"DEBUG: Transformed '{original_comment}' -> '{new_comment}'")
print(f" Original pattern: '{regex_parts[0]}' -> '{regex_parts[1]}'")
print(f" Python pattern: '{regex_parts[0]}' -> '{regex_replacement}'")
except Exception as e:
errors.append(f"Error applying regex to link {link_id} ('{original_comment}'): {e}")
# Continue with original comment
# Check if user already has ACL on target server
target_acl = ACL.objects.filter(user=user, server=target_server).first()
if target_acl:
created = False
else:
# Create new ACL without auto-creating default link
target_acl = ACL(user=user, server=target_server)
target_acl.save(auto_create_link=False)
created = True
# Move the link to target ACL - pure database operation
acl_link.acl = target_acl
acl_link.save()
moved_count += 1
users_processed.add(user.username)
if created:
messages.info(request, f"Created new ACL for user {user.username} on server {target_server.name}")
except ACLLink.DoesNotExist:
errors.append(f"Link with ID {link_id} not found on source server")
except Exception as e:
errors.append(f"Database error moving link {link_id}: {e}")
# Clean up empty ACLs on source server - database operation only
try:
empty_acls = ACL.objects.filter(
server=source_server,
links__isnull=True
)
deleted_acls_count = empty_acls.count()
empty_acls.delete()
except Exception as e:
messages.warning(request, f"Warning: Could not clean up empty ACLs: {e}")
deleted_acls_count = 0
if moved_count > 0:
success_msg = (
f"Successfully moved {moved_count} link(s) for {len(users_processed)} user(s) "
f"from '{source_server.name}' to '{target_server.name}'. "
f"Cleaned up {deleted_acls_count} empty ACL(s)."
)
if comments_transformed > 0:
success_msg += f" Transformed {comments_transformed} comment(s) using regex."
messages.success(request, success_msg)
if errors:
for error in errors:
messages.error(request, error)
return redirect('admin:vpn_server_changelist')
except Exception as e:
messages.error(request, f"Database error during link transfer: {e}")
return redirect('admin:vpn_server_changelist')
def purge_all_keys_action(self, request, queryset):
"""Purge all keys from selected servers without changing database"""
if queryset.count() == 0:
self.message_user(request, "Please select at least one server.", level=messages.ERROR)
return
success_count = 0
error_count = 0
total_keys_removed = 0
for server in queryset:
try:
# Get the real polymorphic instance
real_server = server.get_real_instance()
server_type = type(real_server).__name__
# Check if this is an Outline server
from vpn.server_plugins.outline import OutlineServer
if isinstance(real_server, OutlineServer) and hasattr(real_server, 'client'):
# For Outline servers, get all keys and delete them
try:
keys = real_server.client.get_keys()
keys_count = len(keys)
for key in keys:
try:
real_server.client.delete_key(key.key_id)
except Exception as e:
self.message_user(
request,
f"Failed to delete key {key.key_id} from {server.name}: {e}",
level=messages.WARNING
)
total_keys_removed += keys_count
success_count += 1
self.message_user(
request,
f"Successfully purged {keys_count} keys from server '{server.name}'.",
level=messages.SUCCESS
)
except Exception as e:
error_count += 1
self.message_user(
request,
f"Failed to connect to server '{server.name}': {e}",
level=messages.ERROR
)
else:
self.message_user(
request,
f"Key purging only supported for Outline servers. Skipping '{server.name}' (type: {server_type}).",
level=messages.INFO
)
except Exception as e:
error_count += 1
self.message_user(
request,
f"Unexpected error with server '{server.name}': {e}",
level=messages.ERROR
)
# Summary message
if success_count > 0:
self.message_user(
request,
f"Purge completed! {success_count} server(s) processed, {total_keys_removed} total keys removed. "
f"Database unchanged - run sync to restore proper keys.",
level=messages.SUCCESS
)
if error_count > 0:
self.message_user(
request,
f"{error_count} server(s) had errors during purge.",
level=messages.WARNING
)
purge_all_keys_action.short_description = "🗑️ Purge all keys from server (database unchanged)"
def sync_all_selected_servers(self, request, queryset):
"""Trigger sync for all users on selected servers"""
if queryset.count() == 0:
self.message_user(request, "Please select at least one server.", level=messages.ERROR)
return
try:
from vpn.tasks import sync_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"
@admin.display(description='Server', ordering='name')
def name_with_icon(self, obj):
"""Display server name with type icon"""
icons = {
'outline': '🔵',
'wireguard': '🟢',
}
icon = icons.get(obj.server_type, '')
name_part = f"{icon} {obj.name}" if icon else obj.name
return name_part
@admin.display(description='Comment')
def comment_short(self, obj):
"""Display shortened comment"""
if obj.comment:
short_comment = obj.comment[:40] + '...' if len(obj.comment) > 40 else obj.comment
return mark_safe(f'{short_comment}')
return '-'
@admin.display(description='Users & Links')
def user_stats(self, obj):
"""Display user count and active links statistics"""
try:
from django.utils import timezone
from datetime import timedelta
user_count = obj.user_count if hasattr(obj, 'user_count') else 0
# Get total links count
total_links = ACLLink.objects.filter(acl__server=obj).count()
# Get recently accessed links (last 30 days) - exclude None values
thirty_days_ago = timezone.now() - timedelta(days=30)
active_links = ACLLink.objects.filter(
acl__server=obj,
last_access_time__isnull=False,
last_access_time__gte=thirty_days_ago
).count()
# Color coding based on activity
if user_count == 0:
color = '#9ca3af' # gray - no users
elif total_links == 0:
color = '#dc2626' # red - no links
elif active_links > total_links * 0.7: # High activity
color = '#16a34a' # green
elif active_links > total_links * 0.3: # Medium activity
color = '#eab308' # yellow
else:
color = '#f97316' # orange - low activity
return mark_safe(
f'
' +
f'
👥 {user_count} users
' +
f'
🔗 {active_links}/{total_links} active
' +
f'
'
)
except Exception as e:
return mark_safe(f'Stats error: {e}')
@admin.display(description='Activity')
def activity_summary(self, obj):
"""Display recent activity summary"""
try:
from django.utils import timezone
from datetime import timedelta
# Get recent access logs for this server
seven_days_ago = timezone.now() - timedelta(days=7)
recent_logs = AccessLog.objects.filter(
server=obj.name,
timestamp__gte=seven_days_ago
)
total_access = recent_logs.count()
success_access = recent_logs.filter(action='Success').count()
# Get latest access
latest_log = AccessLog.objects.filter(server=obj.name).order_by('-timestamp').first()
if latest_log:
local_time = localtime(latest_log.timestamp)
latest_str = local_time.strftime('%m-%d %H:%M')
# Time since last access
time_diff = timezone.now() - latest_log.timestamp
if time_diff.days > 0:
time_ago = f'{time_diff.days}d ago'
elif time_diff.seconds > 3600:
time_ago = f'{time_diff.seconds // 3600}h ago'
else:
time_ago = 'Recent'
else:
latest_str = 'Never'
time_ago = ''
# Color coding
if total_access == 0:
color = '#dc2626' # red - no activity
elif total_access > 50:
color = '#16a34a' # green - high activity
elif total_access > 10:
color = '#eab308' # yellow - medium activity
else:
color = '#f97316' # orange - low activity
return mark_safe(
f'
' +
f'
📊 {total_access} uses (7d)
' +
f'
✅ {success_access} success
' +
f'
🕒 {latest_str} {time_ago}
' +
f'
'
)
except Exception as e:
return mark_safe(f'Activity unavailable')
@admin.display(description='Status')
def server_status_compact(self, obj):
"""Display server status in compact format"""
try:
status = obj.get_server_status()
if 'error' in status:
return mark_safe(
f'
'
)
# Extract key metrics
status_items = []
if 'name' in status:
status_items.append(f"📛 {status['name'][:15]}")
if 'version' in status:
status_items.append(f"🔄 {status['version']}")
if 'keys' in status:
status_items.append(f"🔑 {status['keys']} keys")
if 'accessUrl' in status:
status_items.append("🌐 Available")
status_display = ' '.join(status_items) if status_items else 'Unknown'
return mark_safe(
f'
' +
f'✅ Online ' +
f'{status_display}' +
f'
'
)
except Exception as e:
# Don't let server connectivity issues break the admin interface
return mark_safe(
f'
',
portal_url, json_url
)
@admin.display(description='User Statistics Summary')
def user_statistics_summary(self, obj):
"""Display user statistics with integrated server management"""
try:
from .models import UserStatistics
from django.db import models
# Get statistics for this user
user_stats = UserStatistics.objects.filter(user=obj).aggregate(
total_connections=models.Sum('total_connections'),
recent_connections=models.Sum('recent_connections'),
total_links=models.Count('id'),
max_daily_peak=models.Max('max_daily')
)
# Get server breakdown
server_breakdown = UserStatistics.objects.filter(user=obj).values('server_name').annotate(
connections=models.Sum('total_connections'),
links=models.Count('id')
).order_by('-connections')
# Get all ACLs and links for this user
user_acls = ACL.objects.filter(user=obj).select_related('server').prefetch_related('links')
# Get available servers not yet assigned
all_servers = Server.objects.all()
assigned_server_ids = [acl.server.id for acl in user_acls]
unassigned_servers = all_servers.exclude(id__in=assigned_server_ids)
html = '
'
# Overall Statistics
html += '
'
html += f'
'
html += f'
Total Uses: {user_stats["total_connections"] or 0}
'
html += f'
Recent (30d): {user_stats["recent_connections"] or 0}
'
html += f'
Total Links: {user_stats["total_links"] or 0}
'
if user_stats["max_daily_peak"]:
html += f'
Daily Peak: {user_stats["max_daily_peak"]}
'
html += f'
'
html += '
'
# Server Management
if user_acls:
html += '
🔗 Server Access & Links
'
for acl in user_acls:
server = acl.server
links = list(acl.links.all())
# Server status check
try:
server_status = server.get_server_status()
server_accessible = True
server_error = None
except Exception as e:
server_status = {}
server_accessible = False
server_error = str(e)
html += '
'
# Server header
status_icon = '✅' if server_accessible else '❌'
type_icon = '🔵' if server.server_type == 'outline' else '🟢' if server.server_type == 'wireguard' else ''
html += f'
'
html += f'
{type_icon} {server.name} {status_icon}
'
# Server stats
server_stat = next((s for s in server_breakdown if s['server_name'] == server.name), None)
if server_stat:
html += f''
html += f'📊 {server_stat["connections"]} uses ({server_stat["links"]} links)'
html += f''
html += f'
'
# Server error display
if server_error:
html += f'
'
html += f'⚠️ Error: {server_error[:80]}...'
html += f'
'
# Links display
if links:
for link in links:
# Get link stats
link_stats = UserStatistics.objects.filter(
user=obj, server_name=server.name, acl_link_id=link.link
).first()
html += '
'
html += f'
'
html += f'
'
html += f'{link.link[:16]}...' if len(link.link) > 16 else link.link
html += f'
'
if link.comment:
html += f'
{link.comment}
'
html += f'
'
# Link stats and actions
html += f'
'
if link_stats:
html += f''
html += f'✨ {link_stats.total_connections}'
html += f''
# Test link button
html += f'🔗'
# Delete button
html += f''
# Last access
if link.last_access_time:
local_time = localtime(link.last_access_time)
html += f''
html += f'{local_time.strftime("%m-%d %H:%M")}'
html += f''
else:
html += f''
html += f'Never'
html += f''
html += f'
'
# Add link button
html += f'
'
html += f''
html += f'
'
html += '
' # End server-section
# Add server access section
if unassigned_servers:
html += '
'
html += '
➕ Available Servers
'
html += '
'
for server in unassigned_servers:
type_icon = '🔵' if server.server_type == 'outline' else '🟢' if server.server_type == 'wireguard' else ''
html += f''
html += '
'
html += '
' # End user-management-section
return mark_safe(html)
except Exception as e:
return mark_safe(f'Error loading management interface: {e}')
@admin.display(description='Recent Activity')
def recent_activity_display(self, obj):
"""Display recent activity in compact admin-friendly format"""
try:
from datetime import timedelta
from django.utils import timezone
# Get recent access logs for this user (last 7 days, limited)
seven_days_ago = timezone.now() - timedelta(days=7)
recent_logs = AccessLog.objects.filter(
user=obj.username,
timestamp__gte=seven_days_ago
).order_by('-timestamp')[:15] # Limit to 15 most recent
if not recent_logs:
return mark_safe('
No recent activity (last 7 days)
')
html = '
'
# Header
html += '
'
html += f'📊 Recent Activity ({recent_logs.count()} entries, last 7 days)'
html += '
'
# Activity entries
for i, log in enumerate(recent_logs):
bg_color = '#ffffff' if i % 2 == 0 else '#f8f9fa'
local_time = localtime(log.timestamp)
# Status icon and color
if log.action == 'Success':
icon = '✅'
status_color = '#28a745'
elif log.action == 'Failed':
icon = '❌'
status_color = '#dc3545'
else:
icon = 'ℹ️'
status_color = '#6c757d'
html += f'
'
# Left side - server and link info
html += f'
'
html += f'{icon}'
html += f'
'
html += f'
{log.server}
'
if log.acl_link_id:
link_short = log.acl_link_id[:12] + '...' if len(log.acl_link_id) > 12 else log.acl_link_id
html += f'
{link_short}
'
html += f'
'
# Right side - timestamp and status
html += f'
'
html += f'
{local_time.strftime("%m-%d %H:%M")}
'
html += f'
{log.action}
'
html += f'
'
html += f'
'
# Footer with summary if there are more entries
total_recent = AccessLog.objects.filter(
user=obj.username,
timestamp__gte=seven_days_ago
).count()
if total_recent > 15:
html += f'
'
html += f'Showing 15 of {total_recent} entries from last 7 days'
html += f'
'
html += '
'
return mark_safe(html)
except Exception as e:
return mark_safe(f'Error loading activity: {e}')
@admin.display(description='Allowed servers', ordering='server_count')
def server_count(self, obj):
return obj.server_count
def get_queryset(self, request):
qs = super().get_queryset(request)
qs = qs.annotate(server_count=Count('acl'))
return qs
def get_urls(self):
"""Add custom URLs for link management"""
urls = super().get_urls()
custom_urls = [
path('/add-link/', self.admin_site.admin_view(self.add_link_view), name='user_add_link'),
path('/delete-link//', self.admin_site.admin_view(self.delete_link_view), name='user_delete_link'),
path('/add-server-access/', self.admin_site.admin_view(self.add_server_access_view), name='user_add_server_access'),
]
return custom_urls + urls
def add_link_view(self, request, user_id):
"""AJAX view to add a new link for user on specific server"""
from django.http import JsonResponse
if request.method == 'POST':
try:
user = User.objects.get(pk=user_id)
server_id = request.POST.get('server_id')
comment = request.POST.get('comment', '')
if not server_id:
return JsonResponse({'error': 'Server ID is required'}, status=400)
server = Server.objects.get(pk=server_id)
acl = ACL.objects.get(user=user, server=server)
# Create new link
new_link = ACLLink.objects.create(
acl=acl,
comment=comment,
link=shortuuid.ShortUUID().random(length=16)
)
return JsonResponse({
'success': True,
'link_id': new_link.id,
'link': new_link.link,
'comment': new_link.comment,
'url': f"{EXTERNAL_ADDRESS}/ss/{new_link.link}#{server.name}"
})
except Exception as e:
return JsonResponse({'error': str(e)}, status=500)
return JsonResponse({'error': 'Invalid request method'}, status=405)
def delete_link_view(self, request, user_id, link_id):
"""AJAX view to delete a specific link"""
from django.http import JsonResponse
if request.method == 'POST':
try:
user = User.objects.get(pk=user_id)
link = ACLLink.objects.get(pk=link_id, acl__user=user)
link.delete()
return JsonResponse({'success': True})
except Exception as e:
return JsonResponse({'error': str(e)}, status=500)
return JsonResponse({'error': 'Invalid request method'}, status=405)
def add_server_access_view(self, request, user_id):
"""AJAX view to add server access for user"""
from django.http import JsonResponse
if request.method == 'POST':
try:
user = User.objects.get(pk=user_id)
server_id = request.POST.get('server_id')
if not server_id:
return JsonResponse({'error': 'Server ID is required'}, status=400)
server = Server.objects.get(pk=server_id)
# Check if ACL already exists
if ACL.objects.filter(user=user, server=server).exists():
return JsonResponse({'error': 'User already has access to this server'}, status=400)
# Create new ACL (with default link)
acl = ACL.objects.create(user=user, server=server)
return JsonResponse({
'success': True,
'server_name': server.name,
'server_type': server.server_type,
'acl_id': acl.id
})
except Exception as e:
return JsonResponse({'error': str(e)}, status=500)
return JsonResponse({'error': 'Invalid request method'}, status=405)
def change_view(self, request, object_id, form_url='', extra_context=None):
"""Override change view to add user management data to context"""
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)
def save_model(self, request, obj, form, change):
import logging
logger = logging.getLogger(__name__)
super().save_model(request, obj, form, change)
selected_servers = form.cleaned_data.get('servers', [])
# Remove ACLs that are no longer selected
removed_acls = ACL.objects.filter(user=obj).exclude(server__in=selected_servers)
for acl in removed_acls:
logger.info(f"Removing ACL for user {obj.username} from server {acl.server.name}")
removed_acls.delete()
# Create new ACLs for newly selected servers (with default links)
for server in selected_servers:
acl, created = ACL.objects.get_or_create(user=obj, server=server)
if created:
logger.info(f"Created new ACL for user {obj.username} on server {server.name}")
# Note: get_or_create will use the default save() method which creates default links
@admin.register(AccessLog)
class AccessLogAdmin(admin.ModelAdmin):
list_display = ('user', 'server', 'acl_link_display', 'action', 'formatted_timestamp')
list_filter = ('user', 'server', 'action', 'timestamp')
search_fields = ('user', 'server', 'acl_link_id', 'action', 'timestamp', 'data')
readonly_fields = ('server', 'user', 'acl_link_id', 'formatted_timestamp', 'action', 'formated_data')
@admin.display(description='Link', ordering='acl_link_id')
def acl_link_display(self, obj):
if obj.acl_link_id:
return format_html(
'{}',
obj.acl_link_id[:12] + '...' if len(obj.acl_link_id) > 12 else obj.acl_link_id
)
return '-'
@admin.display(description='Timestamp')
def formatted_timestamp(self, obj):
local_time = localtime(obj.timestamp)
return local_time.strftime('%Y-%m-%d %H:%M:%S %Z')
@admin.display(description='Details')
def formated_data(self, obj):
return format_object(obj.data)
class ACLLinkInline(admin.TabularInline):
model = ACLLink
extra = 1
help_text = 'Add or change ACL links'
verbose_name = 'Dynamic link'
verbose_name_plural = 'Dynamic links'
fields = ('link', 'generate_link_button', 'comment')
readonly_fields = ('generate_link_button',)
@admin.display(description="Generate")
def generate_link_button(self, obj=None):
return format_html(
''
)
class Media:
js = ('admin/js/generate_link.js',)
@admin.register(ACL)
class ACLAdmin(admin.ModelAdmin):
list_display = ('user', 'server', 'server_type', 'display_links', 'created_at')
list_filter = (UserNameFilter, 'server__server_type', ServerNameFilter)
# Fixed search_fields - removed problematic polymorphic server fields
search_fields = ('user__username', 'user__comment', 'links__link')
readonly_fields = ('user_info',)
inlines = [ACLLinkInline]
@admin.display(description='Server Type', ordering='server__server_type')
def server_type(self, obj):
return obj.server.get_server_type_display()
@admin.display(description='Client info')
def user_info(self, obj):
server = obj.server
user = obj.user
try:
data = server.get_user(user)
return format_object(data)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Failed to get user info for {user.username} on {server.name}: {e}")
return mark_safe(f"Server connection error: {e}")
@admin.display(description='User Links')
def display_links(self, obj):
links = obj.links.all()
portal_url = f"{EXTERNAL_ADDRESS}/u/{obj.user.hash}"
links_html = []
for link in links:
link_url = f"{EXTERNAL_ADDRESS}/ss/{link.link}#{obj.server.name}"
links_html.append(f"{link.comment} - {link_url}")
links_text = ' '.join(links_html) if links_html else 'No links'
return format_html(
'
{}
' +
'🌐 User Portal',
links_text, portal_url
)
# Note: UserStatistics is not registered separately as admin model.
# All user statistics functionality is integrated into ACLLinkAdmin below.
@admin.register(ACLLink)
class ACLLinkAdmin(admin.ModelAdmin):
list_display = ('link_display', 'user_display', 'server_display', 'comment_display', 'stats_display', 'usage_chart_display', 'last_access_display', 'created_display')
list_filter = ('acl__server__name', 'acl__server__server_type', LastAccessFilter, 'acl__user__username')
search_fields = ('link', 'comment', 'acl__user__username', 'acl__server__name')
list_per_page = 100
actions = ['delete_selected_links', 'update_statistics_action']
list_select_related = ('acl__user', 'acl__server')
def get_queryset(self, request):
qs = super().get_queryset(request)
qs = qs.select_related('acl__user', 'acl__server')
return qs
@admin.display(description='Link', ordering='link')
def link_display(self, obj):
link_url = f"{EXTERNAL_ADDRESS}/ss/{obj.link}#{obj.acl.server.name}"
return format_html(
'{}',
link_url, obj.link[:16] + '...' if len(obj.link) > 16 else obj.link
)
@admin.display(description='User', ordering='acl__user__username')
def user_display(self, obj):
return obj.acl.user.username
@admin.display(description='Server', ordering='acl__server__name')
def server_display(self, obj):
server_type_icons = {
'outline': '🔵',
'wireguard': '🟢',
}
icon = server_type_icons.get(obj.acl.server.server_type, '⚪')
return f"{icon} {obj.acl.server.name}"
@admin.display(description='Comment', ordering='comment')
def comment_display(self, obj):
if obj.comment:
return obj.comment[:50] + '...' if len(obj.comment) > 50 else obj.comment
return '-'
@admin.display(description='Statistics')
def stats_display(self, obj):
try:
from .models import UserStatistics
stats = UserStatistics.objects.get(
user=obj.acl.user,
server_name=obj.acl.server.name,
acl_link_id=obj.link
)
# Color coding based on usage
if stats.total_connections > 100:
color = '#16a34a' # green - high usage
elif stats.total_connections > 10:
color = '#eab308' # yellow - medium usage
elif stats.total_connections > 0:
color = '#f97316' # orange - low usage
else:
color = '#9ca3af' # gray - no usage
return mark_safe(
f'
'
f'✨ {stats.total_connections} total '
f'📅 {stats.recent_connections} last 30d'
f'
'
)
except:
return mark_safe('No cache')
@admin.display(description='30-day Chart')
def usage_chart_display(self, obj):
try:
from .models import UserStatistics
stats = UserStatistics.objects.get(
user=obj.acl.user,
server_name=obj.acl.server.name,
acl_link_id=obj.link
)
if not stats.daily_usage:
return mark_safe('No data')
# Create wider mini chart for better visibility
max_val = max(stats.daily_usage) if stats.daily_usage else 1
chart_html = '
'
# Show last 30 days with wider bars for better visibility
for day_count in stats.daily_usage[-30:]: # Last 30 days
if max_val > 0:
height_percent = (day_count / max_val) * 100
else:
height_percent = 0
color = '#4ade80' if day_count > 0 else '#e5e7eb'
chart_html += f''
chart_html += '
'
# Add summary info below chart
total_last_30 = sum(stats.daily_usage[-30:]) if stats.daily_usage else 0
avg_daily = total_last_30 / 30 if total_last_30 > 0 else 0
chart_html += f'