feat: Implement group-restricted Live Sessions & IP Monitoring and 1-click Admin Email Confirmation

This commit is contained in:
Richard
2026-08-01 12:26:44 +02:00
parent 4b13e57fcb
commit 7614163f78
15 changed files with 943 additions and 665 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
+16 -1
View File
@@ -1,6 +1,15 @@
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from .models import User, RegistrationDomainRule, RegistrationRestrictionSetting from django.contrib.auth.models import Group
from .models import User, RegistrationDomainRule, RegistrationRestrictionSetting, UserActivity
SESSION_MONITOR_GROUP = "Session Monitor"
# Ensure "Session Monitor" group exists in Django Admin
try:
Group.objects.get_or_create(name=SESSION_MONITOR_GROUP)
except Exception:
pass
@admin.register(User) @admin.register(User)
class UserAdmin(BaseUserAdmin): class UserAdmin(BaseUserAdmin):
@@ -18,3 +27,9 @@ class RegistrationDomainRuleAdmin(admin.ModelAdmin):
@admin.register(RegistrationRestrictionSetting) @admin.register(RegistrationRestrictionSetting)
class RegistrationRestrictionSettingAdmin(admin.ModelAdmin): class RegistrationRestrictionSettingAdmin(admin.ModelAdmin):
list_display = ['is_restriction_enabled'] list_display = ['is_restriction_enabled']
@admin.register(UserActivity)
class UserActivityAdmin(admin.ModelAdmin):
list_display = ['user', 'guest_name', 'ip_address', 'last_path', 'last_seen']
list_filter = ['last_seen']
search_fields = ['ip_address', 'guest_name', 'user__username', 'user__email', 'user__display_name', 'last_path']
+44
View File
@@ -0,0 +1,44 @@
from .models import UserActivity
class UserActivityMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
# Record activity for non-static requests
path = request.path
if not path.startswith('/static/') and not path.startswith('/media/'):
try:
# Ensure session exists
if not request.session.session_key:
request.session.create()
session_key = request.session.session_key
# Extract Client IP handling reverse proxies (Traefik / Nginx)
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0].strip()
else:
ip = request.META.get('REMOTE_ADDR', '')
user_agent = request.META.get('HTTP_USER_AGENT', '')[:245]
user = request.user if request.user.is_authenticated else None
guest_name = request.session.get('guest_name', '') or request.COOKIES.get('guest_display_name', '')
if session_key:
UserActivity.objects.update_or_create(
session_key=session_key,
defaults={
'user': user,
'guest_name': guest_name if not user else '',
'ip_address': ip,
'user_agent': user_agent,
'last_path': path[:245],
}
)
except Exception:
pass
return response
@@ -0,0 +1,33 @@
# Generated by Django 5.2.16 on 2026-08-01 10:25
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0006_registrationrestrictionsetting_require_email_verification'),
]
operations = [
migrations.CreateModel(
name='UserActivity',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('guest_name', models.CharField(blank=True, max_length=150, null=True)),
('session_key', models.CharField(db_index=True, max_length=255)),
('ip_address', models.CharField(blank=True, default='', max_length=50)),
('user_agent', models.CharField(blank=True, default='', max_length=255)),
('last_path', models.CharField(blank=True, default='', max_length=255)),
('last_seen', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='activities', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Benutzer-Aktivität / Sitzung',
'verbose_name_plural': 'Benutzer-Aktivitäten / Sitzungen',
'ordering': ['-last_seen'],
},
),
]
+18
View File
@@ -86,3 +86,21 @@ class GuestClaimToken(models.Model):
def __str__(self): def __str__(self):
return f"Claim Token for {self.guest_name} ({self.token})" return f"Claim Token for {self.guest_name} ({self.token})"
class UserActivity(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='activities')
guest_name = models.CharField(max_length=150, null=True, blank=True)
session_key = models.CharField(max_length=255, db_index=True)
ip_address = models.CharField(max_length=50, blank=True, default='')
user_agent = models.CharField(max_length=255, blank=True, default='')
last_path = models.CharField(max_length=255, blank=True, default='')
last_seen = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-last_seen']
verbose_name = 'Benutzer-Aktivität / Sitzung'
verbose_name_plural = 'Benutzer-Aktivitäten / Sitzungen'
def __str__(self):
name = self.user.get_display_name() if self.user else (self.guest_name or "Gast")
return f"{name} ({self.ip_address}) - {self.last_seen}"
+2 -1
View File
@@ -5,7 +5,7 @@ from .views import (
RegistrationDomainRuleViewSet, RegistrationRestrictionSettingView, RegistrationDomainRuleViewSet, RegistrationRestrictionSettingView,
PendingUsersView, ApproveUserView, AdminUserViewSet, PendingUsersView, ApproveUserView, AdminUserViewSet,
GenerateClaimLinkView, ClaimInfoView, GenerateClaimLinkView, ClaimInfoView,
SMTPSettingView, TestSMTPEmailView, UserListView SMTPSettingView, TestSMTPEmailView, UserListView, ActiveSessionsView
) )
router = DefaultRouter() router = DefaultRouter()
@@ -18,6 +18,7 @@ urlpatterns = [
path('login/', LoginView.as_view(), name='login'), path('login/', LoginView.as_view(), name='login'),
path('me/', MeView.as_view(), name='me'), path('me/', MeView.as_view(), name='me'),
path('all/', UserListView.as_view(), name='all-users'), path('all/', UserListView.as_view(), name='all-users'),
path('active-sessions/', ActiveSessionsView.as_view(), name='active-sessions'),
path('restriction-setting/', RegistrationRestrictionSettingView.as_view(), name='restriction-setting'), path('restriction-setting/', RegistrationRestrictionSettingView.as_view(), name='restriction-setting'),
path('smtp-setting/', SMTPSettingView.as_view(), name='smtp-setting'), path('smtp-setting/', SMTPSettingView.as_view(), name='smtp-setting'),
path('smtp-setting/test/', TestSMTPEmailView.as_view(), name='smtp-setting-test'), path('smtp-setting/test/', TestSMTPEmailView.as_view(), name='smtp-setting-test'),
+67 -1
View File
@@ -1,10 +1,14 @@
from rest_framework import views, viewsets, permissions, status from rest_framework import views, viewsets, permissions, status
from rest_framework.decorators import action
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from django.contrib.auth import authenticate, get_user_model from django.contrib.auth import authenticate, get_user_model
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.core.mail import get_connection, EmailMultiAlternatives from django.core.mail import get_connection, EmailMultiAlternatives
from .models import RegistrationDomainRule, RegistrationRestrictionSetting, GuestClaimToken, EmailVerificationToken, SMTPSetting from .models import (
RegistrationDomainRule, RegistrationRestrictionSetting, GuestClaimToken,
EmailVerificationToken, SMTPSetting, UserActivity
)
from .serializers import ( from .serializers import (
UserSerializer, AdminUserSerializer, RegisterSerializer, UserSerializer, AdminUserSerializer, RegisterSerializer,
RegistrationDomainRuleSerializer, RegistrationRestrictionSettingSerializer, SMTPSettingSerializer RegistrationDomainRuleSerializer, RegistrationRestrictionSettingSerializer, SMTPSettingSerializer
@@ -80,6 +84,17 @@ class AdminUserViewSet(viewsets.ModelViewSet):
user_to_delete.delete() user_to_delete.delete()
return Response({'message': f'Benutzer "{name}" wurde erfolgreich gelöscht.'}) return Response({'message': f'Benutzer "{name}" wurde erfolgreich gelöscht.'})
@action(detail=True, methods=['post'], url_path='confirm-email')
def confirm_email(self, request, pk=None):
target_user = self.get_object()
target_user.is_email_verified = True
target_user.is_approved = True
target_user.save()
EmailVerificationToken.objects.filter(user=target_user).update(is_used=True)
return Response({
'message': f'E-Mail-Adresse von {target_user.get_display_name()} ({target_user.email}) wurde manuell von dir als bestätigt markiert.'
})
class RegisterView(views.APIView): class RegisterView(views.APIView):
permission_classes = [permissions.AllowAny] permission_classes = [permissions.AllowAny]
@@ -406,3 +421,54 @@ class UserListView(views.APIView):
def get(self, request): def get(self, request):
users = User.objects.filter(is_active=True).order_by('username') users = User.objects.filter(is_active=True).order_by('username')
return Response(UserSerializer(users, many=True).data) return Response(UserSerializer(users, many=True).data)
SESSION_MONITOR_GROUP = "Session Monitor"
class IsSessionMonitorGroupOrSuperuser(permissions.BasePermission):
def has_permission(self, request, view):
if not request.user or not request.user.is_authenticated:
return False
if request.user.is_superuser:
return True
return request.user.groups.filter(name=SESSION_MONITOR_GROUP).exists()
class ActiveSessionsView(views.APIView):
permission_classes = [IsSessionMonitorGroupOrSuperuser]
def get(self, request):
from datetime import timedelta
from django.utils import timezone
cutoff = timezone.now() - timedelta(hours=24)
activities = UserActivity.objects.filter(last_seen__gte=cutoff).select_related('user').order_by('-last_seen')
data = []
for act in activities:
user_info = None
if act.user:
user_info = {
'id': act.user.id,
'username': act.user.username,
'email': act.user.email,
'display_name': act.user.get_display_name(),
'is_admin': act.user.is_admin_user or act.user.is_staff or act.user.is_superuser,
}
data.append({
'id': act.id,
'user': user_info,
'guest_name': act.guest_name if not act.user else None,
'display_name': act.user.get_display_name() if act.user else (act.guest_name or "Unangemeldeter Gast"),
'session_key': act.session_key[:12] + '...',
'ip_address': act.ip_address or '127.0.0.1',
'user_agent': act.user_agent,
'last_path': act.last_path,
'last_seen': act.last_seen.isoformat(),
})
return Response({
'group_name': SESSION_MONITOR_GROUP,
'is_authorized': True,
'count': len(data),
'sessions': data
})
Binary file not shown.
+1
View File
@@ -71,6 +71,7 @@ MIDDLEWARE = [
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
'apps.users.middleware.UserActivityMiddleware',
] ]
ROOT_URLCONF = 'shiftplan_backend.urls' ROOT_URLCONF = 'shiftplan_backend.urls'
+2 -2
View File
@@ -11,8 +11,8 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<title>Schichtplaner — Veranstaltungsschichtpläne</title> <title>Schichtplaner — Veranstaltungsschichtpläne</title>
<script type="module" crossorigin src="/assets/index-AKhcitQN.js"></script> <script type="module" crossorigin src="/assets/index-RWm1c0SS.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DHeBvKbG.css"> <link rel="stylesheet" crossorigin href="/assets/index-BIEZThrv.css">
</head> </head>
<body class="bg-paper text-main font-sans antialiased min-h-screen"> <body class="bg-paper text-main font-sans antialiased min-h-screen">
<div id="root"></div> <div id="root"></div>
File diff suppressed because it is too large Load Diff