diff --git a/celerybeat-schedule-shm b/celerybeat-schedule-shm
new file mode 100644
index 0000000..2f51277
Binary files /dev/null and b/celerybeat-schedule-shm differ
diff --git a/celerybeat-schedule-wal b/celerybeat-schedule-wal
new file mode 100644
index 0000000..7a67636
Binary files /dev/null and b/celerybeat-schedule-wal differ
diff --git a/cleanup_analysis.sql b/cleanup_analysis.sql
new file mode 100644
index 0000000..b4f444a
--- /dev/null
+++ b/cleanup_analysis.sql
@@ -0,0 +1,15 @@
+-- Проверить количество записей без acl_link_id
+SELECT COUNT(*) as total_without_link
+FROM vpn_accesslog
+WHERE acl_link_id IS NULL OR acl_link_id = '';
+
+-- Проверить общее количество записей
+SELECT COUNT(*) as total_records FROM vpn_accesslog;
+
+-- Показать распределение по датам (последние записи без ссылок)
+SELECT DATE(timestamp) as date, COUNT(*) as count
+FROM vpn_accesslog
+WHERE acl_link_id IS NULL OR acl_link_id = ''
+GROUP BY DATE(timestamp)
+ORDER BY date DESC
+LIMIT 10;
diff --git a/cleanup_options.sql b/cleanup_options.sql
new file mode 100644
index 0000000..adae61a
--- /dev/null
+++ b/cleanup_options.sql
@@ -0,0 +1,35 @@
+-- ВАРИАНТ 1: Удалить ВСЕ записи без acl_link_id
+-- ОСТОРОЖНО! Это удалит все старые логи
+DELETE FROM vpn_accesslog
+WHERE acl_link_id IS NULL OR acl_link_id = '';
+
+-- ВАРИАНТ 2: Удалить записи без acl_link_id старше 30 дней
+-- Более безопасный вариант
+DELETE FROM vpn_accesslog
+WHERE (acl_link_id IS NULL OR acl_link_id = '')
+ AND timestamp < NOW() - INTERVAL 30 DAY;
+
+-- ВАРИАНТ 3: Удалить записи без acl_link_id старше 7 дней
+-- Еще более консервативный подход
+DELETE FROM vpn_accesslog
+WHERE (acl_link_id IS NULL OR acl_link_id = '')
+ AND timestamp < NOW() - INTERVAL 7 DAY;
+
+-- ВАРИАНТ 4: Оставить только последние 1000 записей без ссылок (для истории)
+DELETE FROM vpn_accesslog
+WHERE (acl_link_id IS NULL OR acl_link_id = '')
+ AND id NOT IN (
+ SELECT id FROM (
+ SELECT id FROM vpn_accesslog
+ WHERE acl_link_id IS NULL OR acl_link_id = ''
+ ORDER BY timestamp DESC
+ LIMIT 1000
+ ) AS recent_logs
+ );
+
+-- ВАРИАНТ 5: Поэтапное удаление (для больших БД)
+-- Удаляем по 10000 записей за раз
+DELETE FROM vpn_accesslog
+WHERE (acl_link_id IS NULL OR acl_link_id = '')
+ AND timestamp < NOW() - INTERVAL 30 DAY
+LIMIT 10000;
diff --git a/vpn/admin.py b/vpn/admin.py
index 7108d39..a346322 100644
--- a/vpn/admin.py
+++ b/vpn/admin.py
@@ -1,4 +1,5 @@
import json
+import shortuuid
from polymorphic.admin import (
PolymorphicParentModelAdmin,
)
@@ -578,7 +579,20 @@ class UserAdmin(admin.ModelAdmin):
form = UserForm
list_display = ('username', 'comment', 'registration_date', 'hash_link', 'server_count')
search_fields = ('username', 'hash')
- readonly_fields = ('hash_link',)
+ readonly_fields = ('hash_link', 'user_statistics_summary', 'recent_activity_display')
+
+ fieldsets = (
+ ('User Information', {
+ 'fields': ('username', 'first_name', 'last_name', 'email', 'comment')
+ }),
+ ('Access Information', {
+ 'fields': ('hash_link', 'is_active')
+ }),
+ ('Statistics & Activity', {
+ 'fields': ('user_statistics_summary', 'recent_activity_display'),
+ 'classes': ('collapse',)
+ }),
+ )
@admin.display(description='User Portal', ordering='hash')
def hash_link(self, obj):
@@ -591,15 +605,274 @@ class UserAdmin(admin.ModelAdmin):
'',
portal_url, json_url
)
+
+ @admin.display(description='User Statistics Summary')
+ def user_statistics_summary(self, obj):
+ 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')
+
+ 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'
'
+
+ if server_breakdown:
+ html += '
By Server:
'
+ html += '
'
+ for server in server_breakdown:
+ html += f'{server["server_name"]}: {server["connections"]} uses ({server["links"]} links) '
+ html += ' '
+
+ html += '
'
+ return mark_safe(html)
+ except Exception as e:
+ return mark_safe(f'Error loading statistics: {e} ')
+
+ @admin.display(description='Recent Activity')
+ def recent_activity_display(self, obj):
+ try:
+ from datetime import timedelta
+ from django.utils import timezone
+
+ # Get recent access logs for this user
+ recent_logs = AccessLog.objects.filter(
+ user=obj.username,
+ timestamp__gte=timezone.now() - timedelta(days=7)
+ ).order_by('-timestamp')[:10]
+
+ if not recent_logs:
+ return mark_safe('No recent activity
')
+
+ html = ''
+ html += '
Last 7 days:
'
+
+ for log in recent_logs:
+ local_time = localtime(log.timestamp)
+ time_str = local_time.strftime('%Y-%m-%d %H:%M')
+
+ # Status color coding
+ if log.action == 'Success':
+ color = '#16a34a'
+ icon = '✅'
+ elif log.action == 'Failed':
+ color = '#dc2626'
+ icon = '❌'
+ else:
+ color = '#6b7280'
+ icon = 'ℹ️'
+
+ 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'{icon} {log.server} / {link_display} '
+ html += f'{time_str} '
+ 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 extensive user management data"""
+ 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,
+ })
+
+ 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
diff --git a/vpn/management/commands/cleanup_access_logs.py b/vpn/management/commands/cleanup_access_logs.py
new file mode 100644
index 0000000..bf756aa
--- /dev/null
+++ b/vpn/management/commands/cleanup_access_logs.py
@@ -0,0 +1,158 @@
+from django.core.management.base import BaseCommand
+from django.db import connection, transaction
+from datetime import datetime, timedelta
+from vpn.models import AccessLog
+
+
+class Command(BaseCommand):
+ help = 'Clean up old AccessLog entries without acl_link_id'
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ '--days',
+ type=int,
+ default=30,
+ help='Delete logs older than this many days (default: 30)'
+ )
+ parser.add_argument(
+ '--batch-size',
+ type=int,
+ default=10000,
+ help='Number of records to delete in each batch (default: 10000)'
+ )
+ parser.add_argument(
+ '--dry-run',
+ action='store_true',
+ help='Show what would be deleted without actually deleting'
+ )
+ parser.add_argument(
+ '--keep-recent',
+ type=int,
+ default=1000,
+ help='Keep this many recent logs even if they have no link (default: 1000)'
+ )
+
+ def handle(self, *args, **options):
+ days = options['days']
+ batch_size = options['batch_size']
+ dry_run = options['dry_run']
+ keep_recent = options['keep_recent']
+
+ cutoff_date = datetime.now() - timedelta(days=days)
+
+ self.stdout.write(f"🔍 Analyzing AccessLog cleanup...")
+ self.stdout.write(f" - Delete logs without acl_link_id older than {days} days")
+ self.stdout.write(f" - Keep {keep_recent} most recent logs without links")
+ self.stdout.write(f" - Batch size: {batch_size}")
+ self.stdout.write(f" - Dry run: {dry_run}")
+
+ # Count total records to be deleted
+ with connection.cursor() as cursor:
+ # Count logs without acl_link_id
+ cursor.execute("""
+ SELECT COUNT(*) FROM vpn_accesslog
+ WHERE (acl_link_id IS NULL OR acl_link_id = '')
+ """)
+ total_without_link = cursor.fetchone()[0]
+
+ # Count logs to be deleted (older than cutoff, excluding recent ones to keep)
+ cursor.execute("""
+ SELECT COUNT(*) FROM vpn_accesslog
+ WHERE (acl_link_id IS NULL OR acl_link_id = '')
+ AND timestamp < %s
+ AND id NOT IN (
+ SELECT id FROM (
+ SELECT id FROM vpn_accesslog
+ WHERE acl_link_id IS NULL OR acl_link_id = ''
+ ORDER BY timestamp DESC
+ LIMIT %s
+ ) AS recent_logs
+ )
+ """, [cutoff_date, keep_recent])
+ total_to_delete = cursor.fetchone()[0]
+
+ # Count total records
+ cursor.execute("SELECT COUNT(*) FROM vpn_accesslog")
+ total_records = cursor.fetchone()[0]
+
+ self.stdout.write(f"📊 Statistics:")
+ self.stdout.write(f" - Total AccessLog records: {total_records:,}")
+ self.stdout.write(f" - Records without acl_link_id: {total_without_link:,}")
+ self.stdout.write(f" - Records to be deleted: {total_to_delete:,}")
+ self.stdout.write(f" - Records to be kept (recent): {keep_recent:,}")
+
+ if total_to_delete == 0:
+ self.stdout.write(self.style.SUCCESS("✅ No records to delete."))
+ return
+
+ if dry_run:
+ self.stdout.write(self.style.WARNING(f"🔍 DRY RUN: Would delete {total_to_delete:,} records"))
+ return
+
+ # Confirm deletion
+ if not options.get('verbosity', 1) == 0: # Only ask if not --verbosity=0
+ confirm = input(f"❓ Delete {total_to_delete:,} records? (yes/no): ")
+ if confirm.lower() != 'yes':
+ self.stdout.write("❌ Cancelled.")
+ return
+
+ self.stdout.write(f"🗑️ Starting deletion of {total_to_delete:,} records...")
+
+ deleted_total = 0
+ batch_num = 0
+
+ while True:
+ batch_num += 1
+
+ with transaction.atomic():
+ with connection.cursor() as cursor:
+ # Delete batch
+ cursor.execute("""
+ DELETE FROM vpn_accesslog
+ WHERE (acl_link_id IS NULL OR acl_link_id = '')
+ AND timestamp < %s
+ AND id NOT IN (
+ SELECT id FROM (
+ SELECT id FROM vpn_accesslog
+ WHERE acl_link_id IS NULL OR acl_link_id = ''
+ ORDER BY timestamp DESC
+ LIMIT %s
+ ) AS recent_logs
+ )
+ LIMIT %s
+ """, [cutoff_date, keep_recent, batch_size])
+
+ deleted_in_batch = cursor.rowcount
+
+ if deleted_in_batch == 0:
+ break
+
+ deleted_total += deleted_in_batch
+ progress = (deleted_total / total_to_delete) * 100
+
+ self.stdout.write(
+ f" Batch {batch_num}: Deleted {deleted_in_batch:,} records "
+ f"(Total: {deleted_total:,}/{total_to_delete:,}, {progress:.1f}%)"
+ )
+
+ if deleted_in_batch < batch_size:
+ break
+
+ self.stdout.write(self.style.SUCCESS(f"✅ Cleanup completed!"))
+ self.stdout.write(f" - Deleted {deleted_total:,} old AccessLog records")
+ self.stdout.write(f" - Kept {keep_recent:,} recent records without links")
+
+ # Show final statistics
+ with connection.cursor() as cursor:
+ cursor.execute("SELECT COUNT(*) FROM vpn_accesslog")
+ final_total = cursor.fetchone()[0]
+
+ cursor.execute("""
+ SELECT COUNT(*) FROM vpn_accesslog
+ WHERE acl_link_id IS NULL OR acl_link_id = ''
+ """)
+ final_without_link = cursor.fetchone()[0]
+
+ self.stdout.write(f"📊 Final statistics:")
+ self.stdout.write(f" - Total AccessLog records: {final_total:,}")
+ self.stdout.write(f" - Records without acl_link_id: {final_without_link:,}")
diff --git a/vpn/management/commands/cleanup_logs.py b/vpn/management/commands/cleanup_logs.py
new file mode 100644
index 0000000..178ea22
--- /dev/null
+++ b/vpn/management/commands/cleanup_logs.py
@@ -0,0 +1,208 @@
+from django.core.management.base import BaseCommand
+from django.db import connection
+from django.utils import timezone
+from datetime import timedelta
+
+
+class Command(BaseCommand):
+ help = '''
+ Clean up AccessLog entries efficiently using direct SQL.
+
+ Examples:
+ # Delete logs without acl_link_id older than 30 days (recommended)
+ python manage.py cleanup_logs --keep-days=30
+
+ # Keep only last 5000 logs without acl_link_id
+ python manage.py cleanup_logs --keep-count=5000
+
+ # Delete ALL logs older than 7 days (including with acl_link_id)
+ python manage.py cleanup_logs --keep-days=7 --target=all
+
+ # Preview what would be deleted
+ python manage.py cleanup_logs --keep-days=30 --dry-run
+
+ # Force delete without confirmation
+ python manage.py cleanup_logs --keep-days=30 --force
+ '''
+
+ def add_arguments(self, parser):
+ # Primary options (mutually exclusive)
+ group = parser.add_mutually_exclusive_group(required=True)
+ group.add_argument(
+ '--keep-days',
+ type=int,
+ help='Keep logs newer than this many days (delete older)'
+ )
+ group.add_argument(
+ '--keep-count',
+ type=int,
+ help='Keep this many most recent logs (delete the rest)'
+ )
+
+ parser.add_argument(
+ '--target',
+ choices=['no-links', 'all'],
+ default='no-links',
+ help='Target: "no-links" = only logs without acl_link_id (default), "all" = all logs'
+ )
+
+ parser.add_argument(
+ '--dry-run',
+ action='store_true',
+ help='Show what would be deleted without actually deleting'
+ )
+
+ parser.add_argument(
+ '--force',
+ action='store_true',
+ help='Skip confirmation prompt'
+ )
+
+ def handle(self, *args, **options):
+ keep_days = options.get('keep_days')
+ keep_count = options.get('keep_count')
+ target = options['target']
+ dry_run = options['dry_run']
+ force = options['force']
+
+ # Build SQL conditions
+ if target == 'no-links':
+ base_condition = "(acl_link_id IS NULL OR acl_link_id = '')"
+ target_desc = "logs without acl_link_id"
+ else:
+ base_condition = "1=1"
+ target_desc = "all logs"
+
+ # Get current statistics
+ with connection.cursor() as cursor:
+ # Total records
+ cursor.execute("SELECT COUNT(*) FROM vpn_accesslog")
+ total_records = cursor.fetchone()[0]
+
+ # Target records count
+ cursor.execute(f"SELECT COUNT(*) FROM vpn_accesslog WHERE {base_condition}")
+ target_records = cursor.fetchone()[0]
+
+ # Records to delete
+ if keep_days:
+ cutoff_date = timezone.now() - timedelta(days=keep_days)
+ cursor.execute(f"""
+ SELECT COUNT(*) FROM vpn_accesslog
+ WHERE {base_condition} AND timestamp < %s
+ """, [cutoff_date])
+ to_delete = cursor.fetchone()[0]
+ strategy = f"older than {keep_days} days"
+ else: # keep_count
+ to_delete = max(0, target_records - keep_count)
+ strategy = f"keeping only {keep_count} most recent"
+
+ # Print statistics
+ self.stdout.write("🗑️ AccessLog Cleanup" + (" (DRY RUN)" if dry_run else ""))
+ self.stdout.write(f" Target: {target_desc}")
+ self.stdout.write(f" Strategy: {strategy}")
+ self.stdout.write("")
+ self.stdout.write("📊 Statistics:")
+ self.stdout.write(f" Total AccessLog records: {total_records:,}")
+ self.stdout.write(f" Target records: {target_records:,}")
+ self.stdout.write(f" Records to delete: {to_delete:,}")
+ self.stdout.write(f" Records to keep: {target_records - to_delete:,}")
+
+ if total_records > 0:
+ delete_percent = (to_delete / total_records) * 100
+ self.stdout.write(f" Deletion percentage: {delete_percent:.1f}%")
+
+ if to_delete == 0:
+ self.stdout.write(self.style.SUCCESS("✅ No records to delete."))
+ return
+
+ # Show SQL that will be executed
+ if dry_run or not force:
+ self.stdout.write("")
+ self.stdout.write("📝 SQL to execute:")
+ if keep_days:
+ sql_preview = f"""
+ DELETE FROM vpn_accesslog
+ WHERE {base_condition} AND timestamp < '{cutoff_date.strftime('%Y-%m-%d %H:%M:%S')}'
+ """
+ else: # keep_count
+ sql_preview = f"""
+ DELETE FROM vpn_accesslog
+ WHERE {base_condition}
+ AND id NOT IN (
+ SELECT id FROM (
+ SELECT id FROM vpn_accesslog
+ WHERE {base_condition}
+ ORDER BY timestamp DESC
+ LIMIT {keep_count}
+ ) AS recent_logs
+ )
+ """
+ self.stdout.write(sql_preview.strip())
+
+ if dry_run:
+ self.stdout.write("")
+ self.stdout.write(self.style.WARNING(f"🔍 DRY RUN: Would delete {to_delete:,} records"))
+ return
+
+ # Confirm deletion
+ if not force:
+ self.stdout.write("")
+ self.stdout.write(self.style.ERROR(f"⚠️ About to DELETE {to_delete:,} records!"))
+ confirm = input("Type 'DELETE' to confirm: ")
+ if confirm != 'DELETE':
+ self.stdout.write("❌ Cancelled.")
+ return
+
+ # Execute deletion
+ self.stdout.write("")
+ self.stdout.write(f"🗑️ Deleting {to_delete:,} records...")
+
+ with connection.cursor() as cursor:
+ if keep_days:
+ # Simple time-based deletion
+ cursor.execute(f"""
+ DELETE FROM vpn_accesslog
+ WHERE {base_condition} AND timestamp < %s
+ """, [cutoff_date])
+ else:
+ # Keep count deletion (more complex)
+ cursor.execute(f"""
+ DELETE FROM vpn_accesslog
+ WHERE {base_condition}
+ AND id NOT IN (
+ SELECT id FROM (
+ SELECT id FROM vpn_accesslog
+ WHERE {base_condition}
+ ORDER BY timestamp DESC
+ LIMIT %s
+ ) AS recent_logs
+ )
+ """, [keep_count])
+
+ deleted_count = cursor.rowcount
+
+ # Final statistics
+ with connection.cursor() as cursor:
+ cursor.execute("SELECT COUNT(*) FROM vpn_accesslog")
+ final_total = cursor.fetchone()[0]
+
+ cursor.execute(f"SELECT COUNT(*) FROM vpn_accesslog WHERE {base_condition}")
+ final_target = cursor.fetchone()[0]
+
+ self.stdout.write("")
+ self.stdout.write(self.style.SUCCESS("✅ Cleanup completed!"))
+ self.stdout.write(f" Deleted: {deleted_count:,} records")
+ self.stdout.write(f" Remaining total: {final_total:,}")
+
+ if target == 'no-links':
+ self.stdout.write(f" Remaining without links: {final_target:,}")
+
+ # Calculate space saved (rough estimate)
+ if deleted_count > 0:
+ # Rough estimate: ~200 bytes per AccessLog record
+ space_saved_mb = (deleted_count * 200) / (1024 * 1024)
+ if space_saved_mb > 1024:
+ space_saved_gb = space_saved_mb / 1024
+ self.stdout.write(f" Estimated space saved: ~{space_saved_gb:.1f} GB")
+ else:
+ self.stdout.write(f" Estimated space saved: ~{space_saved_mb:.1f} MB")
diff --git a/vpn/management/commands/simple_cleanup_logs.py b/vpn/management/commands/simple_cleanup_logs.py
new file mode 100644
index 0000000..393cb86
--- /dev/null
+++ b/vpn/management/commands/simple_cleanup_logs.py
@@ -0,0 +1,51 @@
+from django.core.management.base import BaseCommand
+from django.utils import timezone
+from datetime import timedelta
+from vpn.models import AccessLog
+
+
+class Command(BaseCommand):
+ help = 'Simple cleanup of AccessLog entries without acl_link_id using Django ORM'
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ '--days',
+ type=int,
+ default=30,
+ help='Delete logs older than this many days (default: 30)'
+ )
+
+ def handle(self, *args, **options):
+ days = options['days']
+ cutoff_date = timezone.now() - timedelta(days=days)
+
+ # Count records to be deleted
+ old_logs = AccessLog.objects.filter(
+ acl_link_id__isnull=True,
+ timestamp__lt=cutoff_date
+ )
+
+ # Also include empty string acl_link_id
+ empty_logs = AccessLog.objects.filter(
+ acl_link_id='',
+ timestamp__lt=cutoff_date
+ )
+
+ total_old = old_logs.count()
+ total_empty = empty_logs.count()
+ total_to_delete = total_old + total_empty
+
+ self.stdout.write(f"Found {total_to_delete:,} old logs without acl_link_id to delete")
+
+ if total_to_delete == 0:
+ self.stdout.write("Nothing to delete.")
+ return
+
+ # Delete in batches to avoid memory issues
+ self.stdout.write("Deleting old logs...")
+
+ deleted_count = 0
+ deleted_count += old_logs._raw_delete(old_logs.db)
+ deleted_count += empty_logs._raw_delete(empty_logs.db)
+
+ self.stdout.write(self.style.SUCCESS(f"Deleted {deleted_count:,} old AccessLog records"))
diff --git a/vpn/templates/admin/vpn/user/change_form.html b/vpn/templates/admin/vpn/user/change_form.html
new file mode 100644
index 0000000..8cfbc16
--- /dev/null
+++ b/vpn/templates/admin/vpn/user/change_form.html
@@ -0,0 +1,412 @@
+{% extends "admin/change_form.html" %}
+{% load static %}
+
+{% block content_title %}
+
+ {% if original %}
+ 👤 User: {{ original.username }}
+ {% else %}
+ 👤 Add User
+ {% endif %}
+
+{% endblock %}
+
+{% block content %}
+ {{ block.super }}
+
+ {% if original and servers_data %}
+
+
+
🔗 User Access Management
+
+
+
+
📱 User Portal Access
+
+
+
+
+
+ {% for server_name, data in servers_data.items %}
+
+
+
+ {% if data.server.server_type == 'outline' %}🔵{% elif data.server.server_type == 'wireguard' %}🟢{% else %}⚪{% endif %}
+ {{ server_name }}
+
+
+ {% if data.accessible %}
+
+ ✅ Online
+
+ {% else %}
+
+ ❌ Offline
+
+ {% endif %}
+
+
+ {% for stat in data.statistics %}
+ {% if not stat.acl_link_id %}
+
+ 📊 {{ stat.total_connections }} uses
+
+ {% endif %}
+ {% endfor %}
+
+
+
+ {% if data.error %}
+
+
⚠️ Server Error:
+
{{ data.error }}
+
+ {% endif %}
+
+
+ {% if data.status %}
+
+
Server Status:
+
+ {% for key, value in data.status.items %}
+ {{ key }}: {{ value }}
+ {% endfor %}
+
+
+ {% endif %}
+
+
+
+
+ Access Links ({{ data.links|length }}):
+
+
+ {% if data.links %}
+
+ {% for link in data.links %}
+
+
+
+
+ {{ link.link|slice:":16" }}{% if link.link|length > 16 %}...{% endif %}
+
+
+ {{ link.comment|default:"No comment" }}
+
+
+
+
+ {% for stat in data.statistics %}
+ {% if stat.acl_link_id == link.link %}
+
+
+ ✨ {{ stat.total_connections }} total
+
+
+ 📅 {{ stat.recent_connections }} recent
+
+ {% if stat.max_daily > 0 %}
+
+ 📈 {{ stat.max_daily }} peak
+
+ {% endif %}
+
+ {% endif %}
+ {% endfor %}
+
+
+
+
+ 🔗 Test Link
+
+
+
+ 🗑️ Delete
+
+
+ {% if link.last_access_time %}
+
+ Last used: {{ link.last_access_time|date:"Y-m-d H:i" }}
+
+ {% else %}
+
+ Never used
+
+ {% endif %}
+
+
+ {% endfor %}
+
+ {% else %}
+
+ No access links configured for this server
+
+ {% endif %}
+
+
+
+
+ ➕ Add New Link
+
+
+
+
+ {% endfor %}
+
+
+
+ {% if unassigned_servers %}
+
+
➕ Available Servers
+
+ Click to instantly add access to these servers:
+
+
+ {% for server in unassigned_servers %}
+
+ {% if server.server_type == 'outline' %}🔵{% elif server.server_type == 'wireguard' %}🟢{% else %}⚪{% endif %}
+ {{ server.name }}
+
+ {% endfor %}
+
+
+ {% endif %}
+
+ {% endif %}
+
+
+ {% if original and recent_logs %}
+
+
📈 Recent Activity (Last 30 days)
+
+
+
+ Activity Log
+
+
+
+ {% for log in recent_logs %}
+
+
+ {% if log.action == 'Success' %}
+
✅
+ {% elif log.action == 'Failed' %}
+
❌
+ {% else %}
+
ℹ️
+ {% endif %}
+
+
+
{{ log.server }}
+ {% if log.acl_link_id %}
+
+ {{ log.acl_link_id|slice:":16" }}{% if log.acl_link_id|length > 16 %}...{% endif %}
+
+ {% endif %}
+
+
+
+
+
+ {{ log.timestamp|date:"Y-m-d H:i:s" }}
+
+
+ {{ log.action }}
+
+
+
+ {% endfor %}
+
+
+
+ {% endif %}
+{% endblock %}
+
+{% block extrahead %}
+ {{ block.super }}
+
+{% endblock %}
+
+{% block admin_change_form_document_ready %}
+ {{ block.super }}
+
+{% endblock %}