'
+ )
def get_queryset(self, request):
qs = super().get_queryset(request)
@@ -608,6 +797,7 @@ class UserAdmin(admin.ModelAdmin):
@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
@@ -626,71 +816,219 @@ class UserAdmin(admin.ModelAdmin):
links=models.Count('id')
).order_by('-connections')
- html = '
'
- html += f'
'
+ # 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}
'
+
+ # 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 statistics: {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
+ # 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=timezone.now() - timedelta(days=7)
- ).order_by('-timestamp')[:10]
+ timestamp__gte=seven_days_ago
+ ).order_by('-timestamp')[:15] # Limit to 15 most recent
if not recent_logs:
- return mark_safe('
No recent activity
')
+ return mark_safe('
No recent activity (last 7 days)
')
- html = '
'
- html += '
Last 7 days:
'
+ html = '
'
- for log in recent_logs:
+ # 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)
- time_str = local_time.strftime('%Y-%m-%d %H:%M')
- # Status color coding
+ # Status icon and color
if log.action == 'Success':
- color = '#16a34a'
icon = 'â '
+ status_color = '#28a745'
elif log.action == 'Failed':
- color = '#dc2626'
icon = 'â'
+ status_color = '#dc3545'
else:
- color = '#6b7280'
icon = 'âšī¸'
+ status_color = '#6c757d'
- link_display = log.acl_link_id[:12] + '...' if log.acl_link_id and len(log.acl_link_id) > 12 else log.acl_link_id or 'N/A'
+ html += f'
'
- html += f'
'
- html += f'{icon} {log.server} / {link_display}'
- html += f'{time_str}'
+ # 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}')
+ return mark_safe(f'Error loading activity: {e}')
@admin.display(description='Allowed servers', ordering='server_count')
def server_count(self, obj):
@@ -798,74 +1136,14 @@ class UserAdmin(admin.ModelAdmin):
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 extensive user management data"""
+ """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)
-
- # Get all ACLs and links for this user
- user_acls = ACL.objects.filter(user=user).select_related('server').prefetch_related('links')
-
- # Get all available servers
- all_servers = Server.objects.all()
-
- # Get user statistics
- try:
- from .models import UserStatistics
- user_stats = UserStatistics.objects.filter(user=user).select_related('user')
- except:
- user_stats = []
-
- # Get recent access logs
- from django.utils import timezone
- from datetime import timedelta
- recent_logs = AccessLog.objects.filter(
- user=user.username,
- timestamp__gte=timezone.now() - timedelta(days=30)
- ).order_by('-timestamp')[:50]
-
- # Organize data by server
- servers_data = {}
- for acl in user_acls:
- server = acl.server
-
- # Get server status
- 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)
-
- # Get links for this ACL
- links = list(acl.links.all())
-
- # Get statistics for this server
- server_stats = [s for s in user_stats if s.server_name == server.name]
-
- servers_data[server.name] = {
- 'server': server,
- 'acl': acl,
- 'links': links,
- 'statistics': server_stats,
- 'status': server_status,
- 'accessible': server_accessible,
- 'error': server_error,
- }
-
- # Get available servers not yet assigned
- assigned_server_ids = [acl.server.id for acl in user_acls]
- unassigned_servers = all_servers.exclude(id__in=assigned_server_ids)
-
extra_context.update({
'user_object': user,
- 'servers_data': servers_data,
- 'unassigned_servers': unassigned_servers,
- 'recent_logs': recent_logs,
'external_address': EXTERNAL_ADDRESS,
})
diff --git a/vpn/server_plugins/outline.py b/vpn/server_plugins/outline.py
index f2af1a1..08f95f7 100644
--- a/vpn/server_plugins/outline.py
+++ b/vpn/server_plugins/outline.py
@@ -1,6 +1,9 @@
import logging
+import json
import requests
from django.db import models
+from django.shortcuts import render, redirect
+from django.conf import settings
from .generic import Server
from urllib3 import PoolManager
from outline_vpn.outline_vpn import OutlineVPN, OutlineServerErrorException
@@ -301,9 +304,50 @@ class OutlineServerAdmin(PolymorphicChildModelAdmin):
'user_count',
'server_status_inline',
)
- readonly_fields = ('server_status_full', 'registration_date',)
+ readonly_fields = ('server_status_full', 'registration_date', 'export_configuration_display', 'server_statistics_display', 'recent_activity_display', 'json_import_field')
list_editable = ('admin_url', 'admin_access_cert', 'client_hostname', 'client_port',)
exclude = ('server_type',)
+
+ def get_fieldsets(self, request, obj=None):
+ """Customize fieldsets based on whether object exists"""
+ if obj is None: # Adding new server
+ return (
+ ('JSON Import', {
+ 'fields': ('json_import_field',),
+ 'description': 'Quick import from Outline server JSON configuration'
+ }),
+ ('Server Configuration', {
+ 'fields': ('name', 'comment', 'admin_url', 'admin_access_cert', 'client_hostname', 'client_port')
+ }),
+ )
+ else: # Editing existing server
+ return (
+ ('Server Configuration', {
+ 'fields': ('name', 'comment', 'admin_url', 'admin_access_cert', 'client_hostname', 'client_port', 'registration_date')
+ }),
+ ('Server Status', {
+ 'fields': ('server_status_full',)
+ }),
+ ('Export Configuration', {
+ 'fields': ('export_configuration_display',)
+ }),
+ ('Statistics & Users', {
+ 'fields': ('server_statistics_display',),
+ 'classes': ('collapse',)
+ }),
+ ('Recent Activity', {
+ 'fields': ('recent_activity_display',),
+ 'classes': ('collapse',)
+ }),
+ )
+
+ def get_urls(self):
+ from django.urls import path
+ urls = super().get_urls()
+ custom_urls = [
+ path('/sync/', self.admin_site.admin_view(self.sync_server_view), name='outlineserver_sync'),
+ ]
+ return custom_urls + urls
@admin.display(description='Clients', ordering='user_count')
def user_count(self, obj):
@@ -336,8 +380,396 @@ class OutlineServerAdmin(PolymorphicChildModelAdmin):
server_status_full.short_description = "Server Status"
+ def sync_server_view(self, request, object_id):
+ """AJAX view to sync server settings"""
+ from django.http import JsonResponse
+
+ if request.method == 'POST':
+ try:
+ server = OutlineServer.objects.get(pk=object_id)
+ result = server.sync()
+
+ return JsonResponse({
+ 'success': True,
+ 'message': f'Server "{server.name}" synchronized successfully',
+ 'details': result
+ })
+
+ except Exception as e:
+ return JsonResponse({
+ 'success': False,
+ 'error': str(e)
+ }, status=500)
+
+ return JsonResponse({'error': 'Invalid request method'}, status=405)
+
+ def add_view(self, request, form_url='', extra_context=None):
+ """Use the default Django admin add view"""
+ return super().add_view(request, form_url, extra_context)
+
+ @admin.display(description='Import JSON Configuration')
+ def json_import_field(self, obj):
+ """Display JSON import field for new servers only"""
+ if obj and obj.pk:
+ # Hide for existing servers
+ return ''
+
+ html = '''
+
+
+
+ Paste JSON configuration from your Outline server setup to automatically fill the fields below.
+
+
+
+
+
+
+ '''
+
+ return mark_safe(html)
+
+ @admin.display(description='Server Statistics & Users')
+ def server_statistics_display(self, obj):
+ """Display server statistics and user management"""
+ if not obj or not obj.pk:
+ return mark_safe('
Statistics will be available after saving
')
+
+ try:
+ from vpn.models import ACL, AccessLog, UserStatistics
+ from django.utils import timezone
+ from datetime import timedelta
+
+ # Get user statistics
+ user_count = ACL.objects.filter(server=obj).count()
+ total_links = 0
+ server_keys_count = 0
+
+ try:
+ from vpn.models import ACLLink
+ total_links = ACLLink.objects.filter(acl__server=obj).count()
+
+ # Try to get actual keys count from server
+ server_status = obj.get_server_status()
+ if 'keys' in server_status:
+ server_keys_count = server_status['keys']
+ except Exception:
+ pass
+
+ # Get active users count (last 30 days)
+ thirty_days_ago = timezone.now() - timedelta(days=30)
+ active_users_count = UserStatistics.objects.filter(
+ server_name=obj.name,
+ recent_connections__gt=0
+ ).values('user').distinct().count()
+
+ html = '
'
+
+ # Overall Statistics
+ html += '
'
+ html += '
'
+ html += f'
Total Users: {user_count}
'
+ html += f'
Active Users (30d): {active_users_count}
'
+ html += f'
Total Links: {total_links}
'
+ html += f'
Server Keys: {server_keys_count}
'
+ html += '
'
+ html += '
'
+
+ # Get users data with ACL information
+ acls = ACL.objects.filter(server=obj).select_related('user').prefetch_related('links')
+
+ if acls:
+ html += '
đĨ Users with Access
'
+
+ for acl in acls:
+ user = acl.user
+ links = list(acl.links.all())
+
+ # Get last access time from any link
+ last_access = None
+ for link in links:
+ if link.last_access_time:
+ if last_access is None or link.last_access_time > last_access:
+ last_access = link.last_access_time
+
+ # Check if user has key on server
+ server_key = False
+ try:
+ obj.get_user(user)
+ server_key = True
+ except Exception:
+ pass
+
+ html += '
'
+
+ # User info
+ html += '
'
+ html += f'
{user.username}'
+ if user.comment:
+ html += f' - {user.comment}'
+ html += '
'
+ html += f'
{len(links)} link(s)'
+ if last_access:
+ from django.utils.timezone import localtime
+ local_time = localtime(last_access)
+ html += f' | Last access: {local_time.strftime("%Y-%m-%d %H:%M")}'
+ else:
+ html += ' | Never accessed'
+ html += '
'
+ html += '
'
+
+ # Status and actions
+ html += '
'
+ if server_key:
+ html += 'â On Server'
+ else:
+ html += 'â Missing'
+
+ html += f'đ¤ Edit'
+ html += '
'
+ html += '
'
+ else:
+ html += '
'
+ html += 'No users assigned to this server'
+ html += '
')
+
+ @admin.display(description='Recent Activity')
+ def recent_activity_display(self, obj):
+ """Display recent activity in admin-friendly format"""
+ if not obj or not obj.pk:
+ return mark_safe('
Activity will be available after saving
')
+
+ try:
+ from vpn.models import AccessLog
+ from django.utils.timezone import localtime
+ from datetime import timedelta
+ from django.utils import timezone
+
+ # Get recent access logs for this server (last 7 days)
+ seven_days_ago = timezone.now() - timedelta(days=7)
+ recent_logs = AccessLog.objects.filter(
+ server=obj.name,
+ timestamp__gte=seven_days_ago
+ ).order_by('-timestamp')[:20]
+
+ if not recent_logs:
+ return mark_safe('
No recent activity (last 7 days)
')
+
+ html = '
'
+
+ # Header
+ html += '
'
+ html += f'đ Access Log ({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 - user and link info
+ html += '
'
+ html += f'{icon}'
+ html += '
'
+ html += f'
{log.user}
'
+
+ 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 += '
'
+
+ # Right side - timestamp and status
+ html += '
'
+ html += f'
{local_time.strftime("%m-%d %H:%M")}
'
+ html += f'
{log.action}
'
+ html += '
'
+
+ html += '
'
+
+ # Footer with summary if there are more entries
+ total_recent = AccessLog.objects.filter(
+ server=obj.name,
+ timestamp__gte=seven_days_ago
+ ).count()
+
+ if total_recent > 20:
+ html += f'
'
+ html += f'Showing 20 of {total_recent} entries from last 7 days'
+ html += '