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 += '' + + 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

+
+ + 🌐 Open User Portal + + + 📄 JSON API + + + Hash: {{ original.hash }} + +
+
+ + +
+ {% 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 %} + + {% endfor %} +
+ {% else %} +
+ No access links configured for this server +
+ {% endif %} + + +
+ +
+
+
+ {% endfor %} +
+ + + {% if unassigned_servers %} +
+

➕ Available Servers

+
+ Click to instantly add access to these servers: +
+
+ {% for server in unassigned_servers %} + + {% 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 %}