feat: Implement group-restricted Live Sessions & IP Monitoring and 1-click Admin Email Confirmation
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,15 @@
|
||||
from django.contrib import admin
|
||||
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)
|
||||
class UserAdmin(BaseUserAdmin):
|
||||
@@ -18,3 +27,9 @@ class RegistrationDomainRuleAdmin(admin.ModelAdmin):
|
||||
@admin.register(RegistrationRestrictionSetting)
|
||||
class RegistrationRestrictionSettingAdmin(admin.ModelAdmin):
|
||||
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']
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -86,3 +86,21 @@ class GuestClaimToken(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
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}"
|
||||
|
||||
@@ -5,7 +5,7 @@ from .views import (
|
||||
RegistrationDomainRuleViewSet, RegistrationRestrictionSettingView,
|
||||
PendingUsersView, ApproveUserView, AdminUserViewSet,
|
||||
GenerateClaimLinkView, ClaimInfoView,
|
||||
SMTPSettingView, TestSMTPEmailView, UserListView
|
||||
SMTPSettingView, TestSMTPEmailView, UserListView, ActiveSessionsView
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
@@ -18,6 +18,7 @@ urlpatterns = [
|
||||
path('login/', LoginView.as_view(), name='login'),
|
||||
path('me/', MeView.as_view(), name='me'),
|
||||
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('smtp-setting/', SMTPSettingView.as_view(), name='smtp-setting'),
|
||||
path('smtp-setting/test/', TestSMTPEmailView.as_view(), name='smtp-setting-test'),
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
from rest_framework import views, viewsets, permissions, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.authtoken.models import Token
|
||||
from django.contrib.auth import authenticate, get_user_model
|
||||
from django.shortcuts import get_object_or_404
|
||||
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 (
|
||||
UserSerializer, AdminUserSerializer, RegisterSerializer,
|
||||
RegistrationDomainRuleSerializer, RegistrationRestrictionSettingSerializer, SMTPSettingSerializer
|
||||
@@ -80,6 +84,17 @@ class AdminUserViewSet(viewsets.ModelViewSet):
|
||||
user_to_delete.delete()
|
||||
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):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
@@ -406,3 +421,54 @@ class UserListView(views.APIView):
|
||||
def get(self, request):
|
||||
users = User.objects.filter(is_active=True).order_by('username')
|
||||
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.
Binary file not shown.
@@ -71,6 +71,7 @@ MIDDLEWARE = [
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
'apps.users.middleware.UserActivityMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'shiftplan_backend.urls'
|
||||
|
||||
Vendored
+2
-2
@@ -11,8 +11,8 @@
|
||||
<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">
|
||||
<title>Schichtplaner — Veranstaltungsschichtpläne</title>
|
||||
<script type="module" crossorigin src="/assets/index-AKhcitQN.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DHeBvKbG.css">
|
||||
<script type="module" crossorigin src="/assets/index-RWm1c0SS.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BIEZThrv.css">
|
||||
</head>
|
||||
<body class="bg-paper text-main font-sans antialiased min-h-screen">
|
||||
<div id="root"></div>
|
||||
|
||||
+760
-660
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user