diff --git a/vpn/admin.py b/vpn/admin.py
index 792ef6e..dafd997 100644
--- a/vpn/admin.py
+++ b/vpn/admin.py
@@ -17,6 +17,7 @@ from django.utils.timezone import localtime
from vpn.models import User, ACL, ACLLink
from vpn.forms import UserForm
from mysite.settings import EXTERNAL_ADDRESS
+from django.db.models import Max, Subquery, OuterRef, Q
from .server_plugins import (
Server,
WireguardServer,
@@ -33,6 +34,7 @@ class TaskExecutionLogAdmin(admin.ModelAdmin):
ordering = ('-created_at',)
list_per_page = 100
date_hierarchy = 'created_at'
+ actions = ['trigger_full_sync']
fieldsets = (
('Task Information', {
@@ -46,6 +48,55 @@ class TaskExecutionLogAdmin(admin.ModelAdmin):
}),
)
+ def trigger_full_sync(self, request, queryset):
+ """Trigger manual full synchronization of all servers"""
+ # This action doesn't require selected items
+ try:
+ from vpn.tasks import sync_all_users
+ from datetime import timedelta
+ from django.utils import timezone
+
+ # Check if sync is already running (last 10 minutes)
+ recent_cutoff = timezone.now() - timedelta(minutes=10)
+ running_syncs = TaskExecutionLog.objects.filter(
+ created_at__gte=recent_cutoff,
+ task_name='sync_all_servers',
+ status='STARTED'
+ )
+
+ if running_syncs.exists():
+ self.message_user(
+ request,
+ 'Synchronization is already running. Please wait for completion.',
+ level=messages.WARNING
+ )
+ return
+
+ # Start the sync task
+ task = sync_all_users.delay()
+
+ self.message_user(
+ request,
+ f'Full synchronization started successfully. Task ID: {task.id}. Check logs below for progress.',
+ level=messages.SUCCESS
+ )
+
+ except Exception as e:
+ self.message_user(
+ request,
+ f'Failed to start full synchronization: {e}',
+ level=messages.ERROR
+ )
+
+ trigger_full_sync.short_description = "🔄 Trigger full sync of all servers"
+
+ def get_actions(self, request):
+ """Remove default delete action for logs"""
+ actions = super().get_actions(request)
+ if 'delete_selected' in actions:
+ del actions['delete_selected']
+ return actions
+
@admin.display(description='Task', ordering='task_name')
def task_name_display(self, obj):
task_names = {
@@ -87,6 +138,49 @@ class TaskExecutionLogAdmin(admin.ModelAdmin):
def has_change_permission(self, request, obj=None):
return False
+
+ def changelist_view(self, request, extra_context=None):
+ """Override to handle actions that don't require item selection and add sync status"""
+ # Handle actions that don't require selection
+ if 'action' in request.POST:
+ action = request.POST['action']
+ if action == 'trigger_full_sync':
+ # Call the action directly without queryset requirement
+ self.trigger_full_sync(request, None)
+ # Return redirect to prevent AttributeError
+ return redirect(request.get_full_path())
+
+ # Add sync status and controls to the changelist
+ extra_context = extra_context or {}
+
+ # Add sync statistics
+ from datetime import timedelta
+ from django.utils import timezone
+
+ # Get recent sync tasks (last 24 hours)
+ recent_cutoff = timezone.now() - timedelta(hours=24)
+ recent_syncs = TaskExecutionLog.objects.filter(
+ created_at__gte=recent_cutoff,
+ task_name='sync_all_servers'
+ )
+
+ total_recent = recent_syncs.count()
+ successful_recent = recent_syncs.filter(status='SUCCESS').count()
+ failed_recent = recent_syncs.filter(status='FAILURE').count()
+ running_recent = recent_syncs.filter(status='STARTED').count()
+
+ # Check if sync is currently running
+ currently_running = recent_syncs.filter(status='STARTED').exists()
+
+ extra_context.update({
+ 'total_recent_syncs': total_recent,
+ 'successful_recent_syncs': successful_recent,
+ 'failed_recent_syncs': failed_recent,
+ 'running_recent_syncs': running_recent,
+ 'sync_currently_running': currently_running,
+ })
+
+ return super().changelist_view(request, extra_context)
admin.site.site_title = "VPN Manager"
@@ -131,6 +225,40 @@ class ServerNameFilter(admin.SimpleListFilter):
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 no access logs
+ return queryset.filter(last_access__isnull=True)
+ elif self.value() == 'week':
+ # Links accessed in the last week
+ week_ago = timezone.now() - timedelta(days=7)
+ return queryset.filter(last_access__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__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__lt=three_months_ago)
+ return queryset
+
@admin.register(Server)
class ServerAdmin(PolymorphicParentModelAdmin):
base_model = Server
@@ -138,7 +266,7 @@ class ServerAdmin(PolymorphicParentModelAdmin):
list_display = ('name', 'server_type', 'comment', 'registration_date', 'user_count', 'server_status_inline')
search_fields = ('name', 'comment')
list_filter = ('server_type', )
- actions = ['move_clients_action']
+ actions = ['move_clients_action', 'purge_all_keys_action']
def get_urls(self):
urls = super().get_urls()
@@ -355,6 +483,87 @@ class ServerAdmin(PolymorphicParentModelAdmin):
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:
+ # Check if this is an Outline server by checking the actual type
+ from vpn.server_plugins.outline import OutlineServer
+
+ if isinstance(server, OutlineServer) and hasattr(server, 'client'):
+ # For Outline servers, get all keys and delete them
+ try:
+ keys = server.client.get_keys()
+ keys_count = len(keys)
+
+ for key in keys:
+ try:
+ 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:
+ # Show server type for debugging
+ server_type = type(server).__name__
+ 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)"
+
@admin.display(description='User Count', ordering='user_count')
def user_count(self, obj):
return obj.user_count
@@ -507,6 +716,171 @@ class ACLAdmin(admin.ModelAdmin):
links_text, portal_url
)
+
+@admin.register(ACLLink)
+class ACLLinkAdmin(admin.ModelAdmin):
+ list_display = ('link_display', 'user_display', 'server_display', 'comment_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']
+ 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')
+
+ # Add last access annotation
+ qs = qs.annotate(
+ last_access=Subquery(
+ AccessLog.objects.filter(
+ user=OuterRef('acl__user__username'),
+ server=OuterRef('acl__server__name')
+ ).order_by('-timestamp').values('timestamp')[:1]
+ )
+ )
+
+ 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='Last Access', ordering='last_access')
+ def last_access_display(self, obj):
+ if hasattr(obj, 'last_access') and obj.last_access:
+ from django.utils import timezone
+ from datetime import timedelta
+
+ local_time = localtime(obj.last_access)
+ now = timezone.now()
+ diff = now - obj.last_access
+
+ # Color coding based on age
+ if diff <= timedelta(days=7):
+ color = '#16a34a' # green - recent
+ elif diff <= timedelta(days=30):
+ color = '#eab308' # yellow - medium
+ elif diff <= timedelta(days=90):
+ color = '#f97316' # orange - old
+ else:
+ color = '#dc2626' # red - very old
+
+ formatted_date = local_time.strftime('%Y-%m-%d %H:%M')
+
+ # Add relative time info
+ if diff.days > 365:
+ relative = f'{diff.days // 365}y ago'
+ elif diff.days > 30:
+ relative = f'{diff.days // 30}mo ago'
+ elif diff.days > 0:
+ relative = f'{diff.days}d ago'
+ elif diff.seconds > 3600:
+ relative = f'{diff.seconds // 3600}h ago'
+ else:
+ relative = 'Recently'
+
+ return mark_safe(
+ f'{formatted_date}'
+ f'
{relative}'
+ )
+ return mark_safe('Never')
+
+ @admin.display(description='Created', ordering='acl__created_at')
+ def created_display(self, obj):
+ local_time = localtime(obj.acl.created_at)
+ return local_time.strftime('%Y-%m-%d %H:%M')
+
+ def delete_selected_links(self, request, queryset):
+ count = queryset.count()
+ queryset.delete()
+ self.message_user(request, f'Successfully deleted {count} ACL link(s).')
+ delete_selected_links.short_description = "Delete selected ACL links"
+
+ def get_actions(self, request):
+ """Remove default delete action and keep only custom one"""
+ actions = super().get_actions(request)
+ if 'delete_selected' in actions:
+ del actions['delete_selected']
+ return actions
+
+ def has_add_permission(self, request):
+ return False
+
+ def has_change_permission(self, request, obj=None):
+ return True
+
+ def has_delete_permission(self, request, obj=None):
+ return True
+
+ def changelist_view(self, request, extra_context=None):
+ # Add summary statistics to the changelist
+ extra_context = extra_context or {}
+
+ # Get queryset with annotations for statistics
+ queryset = self.get_queryset(request)
+
+ total_links = queryset.count()
+ never_accessed = queryset.filter(last_access__isnull=True).count()
+
+ from django.utils import timezone
+ from datetime import timedelta
+ three_months_ago = timezone.now() - timedelta(days=90)
+ old_links = queryset.filter(
+ Q(last_access__lt=three_months_ago) | Q(last_access__isnull=True)
+ ).count()
+
+ extra_context.update({
+ 'total_links': total_links,
+ 'never_accessed': never_accessed,
+ 'old_links': old_links,
+ })
+
+ return super().changelist_view(request, extra_context)
+
+ def get_ordering(self, request):
+ """Allow sorting by annotated fields"""
+ # Handle sorting by last_access if requested
+ order_var = request.GET.get('o')
+ if order_var:
+ try:
+ field_index = int(order_var.lstrip('-'))
+ # Check if this corresponds to the last_access column (index 4 in list_display)
+ if field_index == 4: # last_access_display is at index 4
+ if order_var.startswith('-'):
+ return ['-last_access']
+ else:
+ return ['last_access']
+ except (ValueError, IndexError):
+ pass
+
+ # Default ordering
+ return ['-acl__created_at', 'acl__user__username']
+
+
try:
from django_celery_results.models import GroupResult, TaskResult
from django_celery_beat.models import (
diff --git a/vpn/migrations/0002_taskexecutionlog.py b/vpn/migrations/0002_taskexecutionlog.py
deleted file mode 100644
index 171aa5f..0000000
--- a/vpn/migrations/0002_taskexecutionlog.py
+++ /dev/null
@@ -1,46 +0,0 @@
-# Generated manually for TaskExecutionLog model
-
-from django.db import migrations, models
-import django.db.models.deletion
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('vpn', '0001_initial'), # This might need to be adjusted
- ]
-
- operations = [
- migrations.CreateModel(
- name='TaskExecutionLog',
- fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('task_id', models.CharField(help_text='Celery task ID', max_length=255)),
- ('task_name', models.CharField(help_text='Task name', max_length=100)),
- ('action', models.CharField(help_text='Action performed', max_length=100)),
- ('status', models.CharField(choices=[('STARTED', 'Started'), ('SUCCESS', 'Success'), ('FAILURE', 'Failure'), ('RETRY', 'Retry')], default='STARTED', max_length=20)),
- ('message', models.TextField(help_text='Detailed execution message')),
- ('execution_time', models.FloatField(blank=True, help_text='Execution time in seconds', null=True)),
- ('created_at', models.DateTimeField(auto_now_add=True)),
- ('server', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vpn.server')),
- ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vpn.user')),
- ],
- options={
- 'verbose_name': 'Task Execution Log',
- 'verbose_name_plural': 'Task Execution Logs',
- 'ordering': ['-created_at'],
- },
- ),
- migrations.AddIndex(
- model_name='taskexecutionlog',
- index=models.Index(fields=['task_id'], name='vpn_taskexe_task_id_e7f4b1_idx'),
- ),
- migrations.AddIndex(
- model_name='taskexecutionlog',
- index=models.Index(fields=['created_at'], name='vpn_taskexe_created_c4a9b5_idx'),
- ),
- migrations.AddIndex(
- model_name='taskexecutionlog',
- index=models.Index(fields=['status'], name='vpn_taskexe_status_1b2c3d_idx'),
- ),
- ]
diff --git a/vpn/templates/admin/vpn/acllink/change_list.html b/vpn/templates/admin/vpn/acllink/change_list.html
new file mode 100644
index 0000000..03ebd53
--- /dev/null
+++ b/vpn/templates/admin/vpn/acllink/change_list.html
@@ -0,0 +1,26 @@
+{% extends "admin/change_list.html" %}
+
+{% block content_title %}
+
+ 💡 Use the "Trigger full sync" action to manually synchronize all servers with current ACL settings. +
+ {% if sync_currently_running %} + + ⚠️ Sync already running - wait for completion + + {% endif %} +